Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 301c3bfb86 | |||
| 5c0db429b3 | |||
| 4d8c7d6566 | |||
| 58810a860b | |||
| d6e6caa223 | |||
| 9a31955fa4 | |||
| e7d7cb2471 | |||
| 8017934334 | |||
| 8b08482d13 | |||
| faa0c7024b | |||
| 7cf04fe24b | |||
| 1e17eeda72 | |||
| 1daea79f64 | |||
| 48de720514 | |||
| fced6b681e | |||
| 80a6be25aa | |||
| 4d0a0b8e09 | |||
| 6184c62721 | |||
| 222a0ff636 | |||
| 28300e19fd | |||
| 024493f2a7 | |||
| edd198cdf5 | |||
| d75c1ae37f | |||
| 8cd2383a42 | |||
| 27bd38e005 | |||
| aa23a72693 | |||
| 4021938046 | |||
| 7486bc2444 | |||
| ee8a1fdc93 | |||
| 8e578d2068 | |||
| cacb280832 | |||
| 36054506c2 | |||
| 5db90844cb | |||
| d5437d517e | |||
| 3085d6f409 | |||
| c5b326c620 | |||
| 389c896d65 | |||
| 41230b5afb | |||
| c245b1ef0b | |||
| 2425a305eb | |||
| 88b161193d | |||
| 9628ed1749 | |||
| 85926f4ec0 | |||
| 47b0894ad6 | |||
| e62fac3a0e | |||
| eae5dcad23 | |||
| 3576e241c0 | |||
| 8f89279fa4 | |||
| b1a66f18bd | |||
| 6a7958c921 | |||
| 33285b53c6 | |||
| 87ad7f4dc2 | |||
| 9c0013f4b6 | |||
| 6129536153 | |||
| e6c3c959fa | |||
| e011b04e04 | |||
| e2866795ef | |||
| e20d7b1438 | |||
| 2a098a78fe | |||
| ece37e9a92 | |||
| 8a1203c4a1 | |||
| 487d1bd430 | |||
| 5c99341b34 | |||
| 8fe3308afd | |||
| 96594ba52b | |||
| 2c61d7a333 | |||
| 8652b86f40 | |||
| 75132a2afe | |||
| 673f98487f | |||
| c556388a6b | |||
| edffdec2b2 | |||
| 1ab21d81ca | |||
| 81794e2475 | |||
| 29309d9bfb | |||
| 70b29567fb | |||
| 2f4d67d3c8 | |||
| b2bfe96559 | |||
| e9dd3e4d2a | |||
| b29875fd30 | |||
| 85cea8d559 | |||
| ab6c3a1354 | |||
| 3aee2276bc | |||
| 9a7d3b2d30 | |||
| 9002cf5559 | |||
| a5e4570f01 | |||
| bfcb9c42a0 | |||
| 799d50024c | |||
| 8c0c4c8600 | |||
| 3cdb416f94 | |||
| d62a3b8134 | |||
| 574bf29a7e | |||
| 24b7c92abd | |||
| 3c4c27fb08 | |||
| 7d15f57e86 | |||
| a9edc12523 | |||
| 96f12d6aac | |||
| 036da9dea8 | |||
| a62a20b599 | |||
| 7c11cdc4d1 | |||
| c3614c6333 | |||
| 9e67088fdb | |||
| 6da6cb5c5a | |||
| 7473e98d91 | |||
| 3df5e5cb3c | |||
| 448c9f2e74 | |||
| 03cdff547d | |||
| f8c93e013d | |||
| 1f02813cc6 | |||
| dc5b8252bb | |||
| 5f3905f2c7 | |||
| e774097fd8 | |||
| 236637fcd3 | |||
| d7fe515940 | |||
| 8258b6c29f | |||
| d10113db54 | |||
| a319e3f66d | |||
| 692d9dab60 | |||
| 087486d253 | |||
| 0662c9d5cc | |||
| e69a5204db | |||
| 4be7e47584 | |||
| 551bbf83c2 | |||
| 43754d03c4 | |||
| 7807e31b22 | |||
| 9a7cfac7f8 | |||
| d37ef56bb1 | |||
| ad7e57fe66 |
@@ -116,6 +116,27 @@ jobs:
|
||||
# Wait for Postgres to accept TCP (no health-check dependency).
|
||||
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
|
||||
|
||||
# Relax durability on the throwaway CI Postgres. Our test pattern
|
||||
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
|
||||
# every test, and the per-TRUNCATE commit fsync is the dominant
|
||||
# cost of the integration suite. The CI DB is rebuilt every run so
|
||||
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
|
||||
# via docker exec because:
|
||||
# - The act_runner `services:` block can't override the container
|
||||
# command, so `postgres -c fsync=off` at boot isn't an option.
|
||||
# - ALTER SYSTEM cannot run inside a transaction; psql -c
|
||||
# auto-commits each statement, which is what we need.
|
||||
# - fsync / full_page_writes are sighup GUCs and
|
||||
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||
# all three up with no restart.
|
||||
# Non-fatal: a perms surprise degrades to "slower", never red CI.
|
||||
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
|
||||
-c "ALTER SYSTEM SET fsync = off" \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = off" \
|
||||
-c "SELECT pg_reload_conf()" \
|
||||
|| echo "WARN: durability relax failed; continuing"
|
||||
|
||||
# Apply embedded migrations to the fresh test DB, then run the
|
||||
# full suite (no -short → integration tests execute). -p 1:
|
||||
# every integration package TRUNCATEs the one shared test DB;
|
||||
|
||||
@@ -173,6 +173,7 @@ dependencies {
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.session)
|
||||
implementation(libs.media3.datasource.okhttp)
|
||||
implementation(libs.mediarouter)
|
||||
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
@@ -186,6 +187,12 @@ dependencies {
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
// kxml2 — provides an org.xmlpull.v1 impl on the JVM unit-test
|
||||
// classpath. Android's stock XmlPullParserFactory resolves to the
|
||||
// android.jar Stub on JVM tests; kxml2 is picked up via service-
|
||||
// provider lookup and makes XmlPullParserFactory.newInstance() work
|
||||
// unconditionally so DeviceDescriptionTest runs in CI.
|
||||
testImplementation(libs.kxml2)
|
||||
// kotlin.test for assertEquals/assertNull/etc. — version managed by
|
||||
// the applied Kotlin plugin so no explicit version pin needed.
|
||||
testImplementation(kotlin("test"))
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".MinstrelApplication"
|
||||
|
||||
@@ -21,6 +21,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
@@ -38,6 +41,7 @@ import javax.inject.Inject
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var seedCache: DetailSeedCache
|
||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||
@Inject lateinit var serverHealth: ServerHealthController
|
||||
|
||||
// Flipped to true when the user taps the media notification (or
|
||||
// any other entry point that asks for the full player). The App
|
||||
@@ -54,6 +58,7 @@ class MainActivity : ComponentActivity() {
|
||||
App(
|
||||
seedCache = seedCache,
|
||||
cachedTrackIds = cachedTrackIds,
|
||||
serverHealth = serverHealth,
|
||||
pendingOpenNowPlaying = pendingOpenNowPlaying.asStateFlow(),
|
||||
onOpenedNowPlaying = { pendingOpenNowPlaying.value = false },
|
||||
)
|
||||
@@ -86,6 +91,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun App(
|
||||
seedCache: DetailSeedCache,
|
||||
cachedTrackIds: CachedTrackIds,
|
||||
serverHealth: ServerHealthController,
|
||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
@@ -93,11 +99,13 @@ private fun App(
|
||||
) {
|
||||
val theme by themeVm.themeMode.collectAsStateWithLifecycle()
|
||||
val cached by cachedTrackIds.ids.collectAsStateWithLifecycle()
|
||||
val health: ServerHealth by serverHealth.state.collectAsStateWithLifecycle()
|
||||
val pending by pendingOpenNowPlaying.collectAsStateWithLifecycle()
|
||||
MinstrelTheme(darkOverride = theme.toDarkOverride()) {
|
||||
CompositionLocalProvider(
|
||||
LocalDetailSeedCache provides seedCache,
|
||||
LocalCachedTrackIds provides cached,
|
||||
LocalServerHealth provides health,
|
||||
) {
|
||||
val startDestination by gate.startDestination.collectAsStateWithLifecycle()
|
||||
val resolved = startDestination
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
||||
import com.fabledsword.minstrel.player.ResumeController
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -121,6 +122,15 @@ class MinstrelApplication :
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — ServerHealthController combines
|
||||
* ConnectivityObserver + VersionCheckController.reachable into the
|
||||
* tri-state ServerHealth signal. Its stateIn is `SharingStarted.Eagerly`
|
||||
* so the StateFlow needs an active subscriber from launch onward; the
|
||||
* @Inject keeps the singleton alive and the flow collecting.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var serverHealthController: ServerHealthController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — UpdateBannerController polls
|
||||
* /api/client/version at launch + every 24h and drives the shell's
|
||||
@@ -158,10 +168,43 @@ class MinstrelApplication :
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||
// Debug builds get the full DebugTree (verbose). Release builds
|
||||
// get a WARN+ tree so operator-driven diagnosis via `adb logcat`
|
||||
// still surfaces UPnP / cast failures, OkHttp errors, and our
|
||||
// own Timber.w / Timber.e calls — without the chatty DEBUG /
|
||||
// INFO traffic flooding the buffer in production.
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
} else {
|
||||
Timber.plant(ReleaseTree())
|
||||
}
|
||||
appScope.launch { resumeController.restore() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Release-build Timber tree: emits at WARN and above only.
|
||||
* `android.util.Log` with the canonical tag so `adb logcat` shows
|
||||
* the line under the standard tag column without falling through
|
||||
* to the package-stack-trace tag DebugTree produces.
|
||||
*/
|
||||
private class ReleaseTree : Timber.Tree() {
|
||||
override fun isLoggable(tag: String?, priority: Int): Boolean =
|
||||
priority >= android.util.Log.WARN
|
||||
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
val resolvedTag = tag ?: "Minstrel"
|
||||
if (t == null) {
|
||||
android.util.Log.println(priority, resolvedTag, message)
|
||||
} else {
|
||||
android.util.Log.println(
|
||||
priority,
|
||||
resolvedTag,
|
||||
message + '\n' + android.util.Log.getStackTraceString(t),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit interface for the cast-token endpoint. Used by the UPnP
|
||||
* selection path in `OutputPickerController` to obtain a signed stream
|
||||
* URL that a network speaker can fetch without the user's session
|
||||
* cookie — those devices cannot carry the session, so the signed
|
||||
* query string is the only way they can fetch the bytes.
|
||||
*
|
||||
* Endpoint: `POST /api/cast/stream-token`
|
||||
* Auth: standard session cookie (`AuthCookieInterceptor` handles it).
|
||||
*
|
||||
* Server contract lives at `internal/api/cast_token.go`
|
||||
* (commit e774097f). Field names here mirror the server's `json:`
|
||||
* tags verbatim — `trackId` / `expSeconds` — so no `@SerialName` is
|
||||
* needed on the request, and `token` / `exp` / `url` map straight
|
||||
* through on the response.
|
||||
*/
|
||||
interface CastApi {
|
||||
@POST("api/cast/stream-token")
|
||||
suspend fun streamToken(@Body req: StreamTokenRequest): StreamTokenResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body. [expSeconds] is clamped server-side to [60, 86400];
|
||||
* the 21_600 default (6h) is long enough to play through any typical
|
||||
* track without re-minting mid-playback.
|
||||
*/
|
||||
@Serializable
|
||||
data class StreamTokenRequest(
|
||||
val trackId: String,
|
||||
val expSeconds: Int = 21_600,
|
||||
)
|
||||
|
||||
/**
|
||||
* Response body. [url] is a fully-formed stream URL with [token] and
|
||||
* [exp] already embedded as query params — callers pass it verbatim
|
||||
* to `AVTransport.SetAVTransportURI`. [mime] + [title] are the bits
|
||||
* the client needs to build DIDL-Lite metadata for that call: Sonos
|
||||
* rejects empty DIDL with vendor error 1023, so the server hands back
|
||||
* the track's MIME (from `tracks.file_format`) and title so the
|
||||
* client can populate `<res protocolInfo>` and `<dc:title>` without
|
||||
* a follow-up round trip.
|
||||
*/
|
||||
@Serializable
|
||||
data class StreamTokenResponse(
|
||||
val token: String,
|
||||
val exp: Long,
|
||||
val url: String,
|
||||
val mime: String = "audio/mpeg",
|
||||
val title: String = "",
|
||||
)
|
||||
+8
@@ -24,6 +24,14 @@ interface CachedAlbumDao {
|
||||
@Query("SELECT * FROM cached_albums WHERE id = :id")
|
||||
fun observeById(id: String): Flow<CachedAlbumEntity?>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM cached_albums " +
|
||||
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY sortTitle COLLATE NOCASE ASC " +
|
||||
"LIMIT :limit",
|
||||
)
|
||||
suspend fun searchByTitle(q: String, limit: Int): List<CachedAlbumEntity>
|
||||
|
||||
@Query("SELECT id FROM cached_albums WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
|
||||
+8
@@ -18,6 +18,14 @@ interface CachedArtistDao {
|
||||
@Query("SELECT * FROM cached_artists WHERE id = :id")
|
||||
fun observeById(id: String): Flow<CachedArtistEntity?>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM cached_artists " +
|
||||
"WHERE name LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY sortName COLLATE NOCASE ASC " +
|
||||
"LIMIT :limit",
|
||||
)
|
||||
suspend fun searchByName(q: String, limit: Int): List<CachedArtistEntity>
|
||||
|
||||
@Query("SELECT id FROM cached_artists WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
|
||||
+8
@@ -24,6 +24,14 @@ interface CachedTrackDao {
|
||||
@Query("SELECT * FROM cached_tracks WHERE id IN (:ids)")
|
||||
suspend fun getByIds(ids: List<String>): List<CachedTrackEntity>
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM cached_tracks " +
|
||||
"WHERE title LIKE '%' || :q || '%' COLLATE NOCASE " +
|
||||
"ORDER BY title COLLATE NOCASE ASC " +
|
||||
"LIMIT :limit",
|
||||
)
|
||||
suspend fun searchByTitle(q: String, limit: Int): List<CachedTrackEntity>
|
||||
|
||||
@Query("SELECT id FROM cached_tracks WHERE fetchedAt < :before LIMIT :limit")
|
||||
suspend fun idsStaleBefore(before: Long, limit: Int): List<String>
|
||||
|
||||
|
||||
+63
-43
@@ -2,12 +2,18 @@ package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val QUEUED_MESSAGE = "Saved — will sync when online"
|
||||
|
||||
/**
|
||||
* Stable mutation kinds the queue knows how to replay. Strings are
|
||||
* persisted in `cached_mutations.kind` so renaming a variant breaks
|
||||
@@ -71,47 +77,59 @@ class MutationQueue @Inject constructor(
|
||||
private val dao: CachedMutationDao,
|
||||
private val json: Json,
|
||||
) {
|
||||
// capacity=1 DROP_OLDEST so a burst of user enqueues (e.g. liking N
|
||||
// tracks while offline) surfaces as one snackbar rather than queueing
|
||||
// N. replay=0 because a hint observed at enqueue time isn't useful
|
||||
// to a screen that mounts later.
|
||||
private val _userEnqueueHints = MutableSharedFlow<String>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hint stream consumed by [com.fabledsword.minstrel.shared.widgets.ShellScaffold]
|
||||
* to surface "Saved — will sync when online" as a snackbar whenever a
|
||||
* user-driven write hits the offline-fallback path. Background-only
|
||||
* enqueues (play-events, playback-error reports) do not emit — those
|
||||
* fire from non-foreground paths where a snackbar would be either
|
||||
* dropped (no shell mounted) or jarring (lock-screen toggle).
|
||||
*/
|
||||
val userEnqueueHints: SharedFlow<String> = _userEnqueueHints.asSharedFlow()
|
||||
|
||||
suspend fun enqueueLikeToggle(
|
||||
entityType: String,
|
||||
entityId: String,
|
||||
desiredState: Boolean,
|
||||
): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.LIKE_TOGGLE,
|
||||
payload = json.encodeToString(
|
||||
LikeTogglePayload.serializer(),
|
||||
LikeTogglePayload(entityType, entityId, desiredState),
|
||||
),
|
||||
): Long = insertUserDriven(
|
||||
MutationKind.LIKE_TOGGLE,
|
||||
json.encodeToString(
|
||||
LikeTogglePayload.serializer(),
|
||||
LikeTogglePayload(entityType, entityId, desiredState),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.REQUEST_CREATE,
|
||||
payload = json.encodeToString(RequestCreatePayload.serializer(), payload),
|
||||
),
|
||||
suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = insertUserDriven(
|
||||
MutationKind.REQUEST_CREATE,
|
||||
json.encodeToString(RequestCreatePayload.serializer(), payload),
|
||||
)
|
||||
|
||||
suspend fun enqueueQuarantineUnflag(trackId: String): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.QUARANTINE_UNFLAG,
|
||||
payload = json.encodeToString(
|
||||
QuarantineUnflagPayload.serializer(),
|
||||
QuarantineUnflagPayload(trackId),
|
||||
),
|
||||
suspend fun enqueueQuarantineUnflag(trackId: String): Long = insertUserDriven(
|
||||
MutationKind.QUARANTINE_UNFLAG,
|
||||
json.encodeToString(
|
||||
QuarantineUnflagPayload.serializer(),
|
||||
QuarantineUnflagPayload(trackId),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueuePlaylistAppend(
|
||||
playlistId: String,
|
||||
trackIds: List<String>,
|
||||
): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.PLAYLIST_APPEND,
|
||||
payload = json.encodeToString(
|
||||
PlaylistAppendPayload.serializer(),
|
||||
PlaylistAppendPayload(playlistId, trackIds),
|
||||
),
|
||||
): Long = insertUserDriven(
|
||||
MutationKind.PLAYLIST_APPEND,
|
||||
json.encodeToString(
|
||||
PlaylistAppendPayload.serializer(),
|
||||
PlaylistAppendPayload(playlistId, trackIds),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -119,13 +137,19 @@ class MutationQueue @Inject constructor(
|
||||
trackId: String,
|
||||
reason: String,
|
||||
notes: String,
|
||||
): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.QUARANTINE_FLAG,
|
||||
payload = json.encodeToString(
|
||||
QuarantineFlagPayload.serializer(),
|
||||
QuarantineFlagPayload(trackId, reason, notes),
|
||||
),
|
||||
): Long = insertUserDriven(
|
||||
MutationKind.QUARANTINE_FLAG,
|
||||
json.encodeToString(
|
||||
QuarantineFlagPayload.serializer(),
|
||||
QuarantineFlagPayload(trackId, reason, notes),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||
MutationKind.REQUEST_CANCEL,
|
||||
json.encodeToString(
|
||||
RequestCancelPayload.serializer(),
|
||||
RequestCancelPayload(requestId),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -136,22 +160,18 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueueRequestCancel(requestId: String): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.REQUEST_CANCEL,
|
||||
payload = json.encodeToString(
|
||||
RequestCancelPayload.serializer(),
|
||||
RequestCancelPayload(requestId),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.PLAYBACK_ERROR_REPORT,
|
||||
payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload),
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun insertUserDriven(kind: String, payload: String): Long {
|
||||
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
|
||||
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Hilt-injectable wrapper exposing [MutationQueue.userEnqueueHints] to
|
||||
* ShellScaffold. The queue itself is an app-scoped singleton; this VM
|
||||
* just bridges its SharedFlow into a `hiltViewModel()`-resolvable
|
||||
* surface so ShellScaffold can collect it without an EntryPoint
|
||||
* accessor. Mirrors PlaybackErrorViewModel.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class OfflineWriteHintViewModel @Inject constructor(
|
||||
mutationQueue: MutationQueue,
|
||||
) : ViewModel() {
|
||||
val messages: Flow<String> = mutationQueue.userEnqueueHints
|
||||
}
|
||||
+27
-15
@@ -14,11 +14,23 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Single source of truth for the device's "is the internet usable
|
||||
* right now" signal — wraps [ConnectivityManager] and exposes a hot
|
||||
* cold-startable Flow that emits `false` while the active network
|
||||
* lacks INTERNET + VALIDATED capabilities (airplane mode, no carrier,
|
||||
* captive portal, etc.) and `true` once a usable network appears.
|
||||
* Single source of truth for "does the device have a network link at
|
||||
* all" — wraps [ConnectivityManager] and exposes a hot cold-startable
|
||||
* Flow that emits `false` only when there is no active INTERNET-capable
|
||||
* network (airplane mode, no carrier/Wi-Fi) and `true` once any network
|
||||
* link appears.
|
||||
*
|
||||
* Deliberately does NOT require `NET_CAPABILITY_VALIDATED`. VALIDATED
|
||||
* tracks whether Android reached its own WAN internet-validation probe
|
||||
* (Google's `generate_204`) — which is the wrong question for a
|
||||
* self-hosted server that is usually on the LAN. A transient WAN/DNS
|
||||
* blip (or Android's periodic re-validation) momentarily drops VALIDATED
|
||||
* while the Minstrel box stays perfectly reachable; gating on it flipped
|
||||
* the app to Offline with no debounce and fast-failed in-flight playback
|
||||
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
|
||||
* authority on whether *Minstrel* is reachable is the `/healthz` poll
|
||||
* ([com.fabledsword.minstrel.update.data.VersionCheckController], which
|
||||
* has its own failure hysteresis), not this coarse device-link signal.
|
||||
*
|
||||
* Used by the shell-level ConnectionErrorBanner; downstream
|
||||
* repositories can also collect this to gate retry loops.
|
||||
@@ -35,36 +47,36 @@ class ConnectivityObserver @Inject constructor(
|
||||
.build()
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(hasUsableInternet())
|
||||
trySend(hasActiveNetwork())
|
||||
}
|
||||
override fun onLost(network: Network) {
|
||||
trySend(hasUsableInternet())
|
||||
trySend(hasActiveNetwork())
|
||||
}
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
capabilities: NetworkCapabilities,
|
||||
) {
|
||||
// INTERNET only -- NOT VALIDATED. A WAN/validation flicker
|
||||
// must not read as "device offline" when the LAN (and the
|
||||
// Minstrel server on it) is still reachable. /healthz is the
|
||||
// authority on server reachability.
|
||||
trySend(
|
||||
capabilities.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_INTERNET,
|
||||
) &&
|
||||
capabilities.hasCapability(
|
||||
NetworkCapabilities.NET_CAPABILITY_VALIDATED,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
// Seed the initial value so the banner doesn't flash before the
|
||||
// first capability callback fires.
|
||||
trySend(hasUsableInternet())
|
||||
trySend(hasActiveNetwork())
|
||||
awaitClose { cm.unregisterNetworkCallback(callback) }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
private fun hasUsableInternet(): Boolean {
|
||||
private fun hasActiveNetwork(): Boolean {
|
||||
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
|
||||
return caps != null &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Tri-state server-reachability signal that downstream consumers can branch
|
||||
* on to decide whether to hit the network, gate writes, or fall back to
|
||||
* cache-only behavior.
|
||||
*
|
||||
* Composed from two existing signals -- this controller doesn't poll its own
|
||||
* endpoint:
|
||||
*
|
||||
* - [ConnectivityObserver.online] -- system-level NetworkCallback that
|
||||
* answers only "is there an active INTERNET-capable network link" (NOT
|
||||
* VALIDATED -- a WAN-validation flicker must not read as offline when
|
||||
* the LAN server is reachable).
|
||||
* - [VersionCheckController.reachable] -- did the last `/healthz` poll
|
||||
* succeed. This is the authority on whether *Minstrel* is reachable;
|
||||
* it has its own failure hysteresis. Distinguishes "device has a link
|
||||
* but our server is down" from "no network at all."
|
||||
*
|
||||
* `version too old` is intentionally *not* folded in here -- it's a separate
|
||||
* UX (the VersionTooOldBanner) and conflating it with offline would mask the
|
||||
* real cause.
|
||||
*/
|
||||
enum class ServerHealth { Healthy, Offline, ServerDown }
|
||||
|
||||
@Singleton
|
||||
class ServerHealthController @Inject constructor(
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
connectivity: ConnectivityObserver,
|
||||
versionCheck: VersionCheckController,
|
||||
) {
|
||||
val state: StateFlow<ServerHealth> = combine(
|
||||
connectivity.online,
|
||||
versionCheck.reachable,
|
||||
) { online, serverReachable ->
|
||||
when {
|
||||
!online -> ServerHealth.Offline
|
||||
!serverReachable -> ServerHealth.ServerDown
|
||||
else -> ServerHealth.Healthy
|
||||
}
|
||||
}
|
||||
// Transition log -- the signal had no instrumentation, which is why a
|
||||
// false-offline (WAN flicker flipping playback to "Source error") was
|
||||
// hard to diagnose from logcat. WARN-tier so ReleaseTree surfaces it.
|
||||
.distinctUntilChanged()
|
||||
.onEach { Timber.w("ServerHealth -> %s", it) }
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = ServerHealth.Healthy,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive [ServerHealth] snapshot provided once at the app root from the
|
||||
* controller's StateFlow. Lets leaf composables (TrackRow gating, write-
|
||||
* affordance disabling) branch on health without each ViewModel re-
|
||||
* injecting the controller. Defaults to Healthy so unwrapped previews
|
||||
* and tests don't crash.
|
||||
*/
|
||||
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
|
||||
+22
-16
@@ -25,45 +25,45 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.composables.icons.lucide.CloudOff
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val ONLINE_SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
/**
|
||||
* Tiny VM that just lifts the [ConnectivityObserver] singleton's
|
||||
* Flow into a StateFlow with the standard sharing strategy. Keeps
|
||||
* the banner composable pure-presentation.
|
||||
* Lifts [ServerHealthController]'s tri-state into a StateFlow for the banner
|
||||
* composable. Keeps the banner pure-presentation.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ConnectivityBannerViewModel @Inject constructor(
|
||||
observer: ConnectivityObserver,
|
||||
health: ServerHealthController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val online: StateFlow<Boolean> = observer.online.stateIn(
|
||||
val health: StateFlow<ServerHealth> = health.state.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(ONLINE_SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = true,
|
||||
started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = ServerHealth.Healthy,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner shown at the top of the shell when the device has no usable
|
||||
* internet. Mirrors Flutter's ConnectionErrorBanner: red-tinted error
|
||||
* surface, CloudOff icon, "No connection — check Wi-Fi or mobile
|
||||
* data" copy. Auto-hides via slide+fade when connectivity returns.
|
||||
* Banner shown at the top of the shell when the user can't reach the server.
|
||||
* Tri-state so we tell the user *why*: no device network vs server-down.
|
||||
* Copy choices match the Flutter analogues. Auto-hides via slide+fade when
|
||||
* health returns to [ServerHealth.Healthy].
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionErrorBanner(
|
||||
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val online by viewModel.online.collectAsStateWithLifecycle()
|
||||
val health by viewModel.health.collectAsStateWithLifecycle()
|
||||
AnimatedVisibility(
|
||||
visible = !online,
|
||||
visible = health != ServerHealth.Healthy,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
@@ -81,7 +81,13 @@ fun ConnectionErrorBanner(
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = "No connection — check Wi-Fi or mobile data.",
|
||||
text = when (health) {
|
||||
ServerHealth.Offline ->
|
||||
"No connection — check Wi-Fi or mobile data."
|
||||
ServerHealth.ServerDown ->
|
||||
"Server unreachable — your cached content is still available."
|
||||
ServerHealth.Healthy -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
|
||||
@@ -69,7 +69,7 @@ import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.Home
|
||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
|
||||
import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
||||
@@ -95,11 +95,9 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
|
||||
private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
||||
// Recently Added is laid out in a multi-row LazyHorizontalGrid that
|
||||
// scrolls as one panel (same pattern as Most Played). Two rows trades
|
||||
@@ -259,45 +257,9 @@ class HomeViewModel @Inject constructor(
|
||||
*/
|
||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||
viewModelScope.launch {
|
||||
val detail = try {
|
||||
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
|
||||
if (playlist.refreshable && playlist.systemVariant != null) {
|
||||
playlistsRepository.systemShuffle(playlist.systemVariant!!)
|
||||
} else {
|
||||
playlistsRepository.refreshDetail(playlist.id)
|
||||
}
|
||||
}
|
||||
} catch (
|
||||
@Suppress("SwallowedException") _: kotlinx.coroutines.TimeoutCancellationException,
|
||||
) {
|
||||
poolMessages.trySend("Couldn't load playlist - check your connection")
|
||||
return@launch
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
poolMessages.trySend(
|
||||
"Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
return@launch
|
||||
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
||||
poolMessages.trySend(it)
|
||||
}
|
||||
// Shared with PlaylistDetailViewModel.play - filters out
|
||||
// unplayable rows (missing trackId or empty streamUrl) so the
|
||||
// queue can't end up with tracks Media3 silently rejects.
|
||||
val tracks = detail.tracks.toPlayableTrackRefs()
|
||||
if (tracks.isEmpty()) {
|
||||
poolMessages.trySend("Mix isn't ready yet - try again in a moment")
|
||||
return@launch
|
||||
}
|
||||
// Drift #564: send the BARE systemVariant string, not
|
||||
// "playlist:<variant>" — the server's rotation matcher
|
||||
// (internal/playevents/writer.go systemPlaylistSources)
|
||||
// keys on the bare variant. Web sends the bare form too
|
||||
// (web/src/lib/components/PlaylistCard.svelte:83), so this
|
||||
// brings Android into alignment. Wrong prefix here meant
|
||||
// system-mix plays from Android Home never advanced the
|
||||
// rotation.
|
||||
val source = if (playlist.refreshable) playlist.systemVariant else null
|
||||
player.setQueue(tracks, initialIndex = 0, source = source)
|
||||
}.join()
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -56,8 +55,7 @@ class AlbumDetailViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
likes.observeLikedTracks()
|
||||
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
|
||||
@@ -110,6 +110,20 @@ class LikesRepository @Inject constructor(
|
||||
fun observeIsLiked(entityType: String, entityId: String): Flow<Boolean> =
|
||||
likeDao.observeIsLiked(currentUserId(), entityType, entityId)
|
||||
|
||||
/**
|
||||
* Reactive set of liked track ids. Use this when the caller only
|
||||
* needs "is X in liked set", NOT when it needs to render the liked
|
||||
* tracks themselves — [observeLikedTracks] does a `mapNotNull` join
|
||||
* against `trackDao` and drops any liked id whose track isn't in
|
||||
* local cache yet, so an "is liked" UI built on `observeLikedTracks`
|
||||
* misses cross-device likes whose track row hasn't cached.
|
||||
*
|
||||
* Used by playlist/album detail screens to color a row's like
|
||||
* button independent of whether the track is in the local library.
|
||||
*/
|
||||
fun observeLikedTrackIds(): Flow<Set<String>> =
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
|
||||
|
||||
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
|
||||
suspend fun likedTrackIds(): Set<String> =
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
|
||||
|
||||
@@ -58,59 +58,98 @@ class AudioPrefetcher @Inject constructor(
|
||||
private val activeJobs = mutableMapOf<String, Job>()
|
||||
private val mutex = Mutex()
|
||||
|
||||
private data class ReconcileInput(
|
||||
val queue: List<Pair<String, String>>,
|
||||
val index: Int,
|
||||
val window: Int,
|
||||
val isPlaying: Boolean,
|
||||
)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
combine(
|
||||
playerController.uiState.map { it.queue.map { t -> t.id to t.streamUrl } },
|
||||
playerController.uiState.map { it.queueIndex },
|
||||
authStore.cacheSettings.map { it.prefetchWindow },
|
||||
) { queue, index, window -> Triple(queue, index, window) }
|
||||
playerController.uiState.map { it.isPlaying },
|
||||
) { queue, index, window, isPlaying ->
|
||||
ReconcileInput(queue, index, window, isPlaying)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { (queue, index, window) -> reconcile(queue, index, window) }
|
||||
.collect { input ->
|
||||
reconcile(input.queue, input.index, input.window, input.isPlaying)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the prefetch window to the current queue.
|
||||
*
|
||||
* Cancellation of out-of-window jobs always runs -- a queue mutation
|
||||
* or skip should free bandwidth from stale prefetches immediately.
|
||||
* Starting new prefetches is gated on [isPlaying]: until the current
|
||||
* track is actually playing, every byte of upstream bandwidth should
|
||||
* land on it, not on upcoming-track prefetches. Without this gate a
|
||||
* cold start fanned out 4-6 concurrent CacheWriter jobs against the
|
||||
* same OkHttp client as the playback DataSource and the user waited
|
||||
* ~25 s for the first audio to start; with the gate the current
|
||||
* track gets the full pipe to its first STATE_READY, then the
|
||||
* prefetcher fills in the next window.
|
||||
*/
|
||||
private suspend fun reconcile(
|
||||
queue: List<Pair<String, String>>,
|
||||
index: Int,
|
||||
window: Int,
|
||||
isPlaying: Boolean,
|
||||
) {
|
||||
mutex.withLock {
|
||||
if (index < 0 || queue.isEmpty() || window <= 0) {
|
||||
val targets = computeTargets(queue, index, window)
|
||||
if (targets.isEmpty()) {
|
||||
cancelAllLocked()
|
||||
return
|
||||
}
|
||||
// Exclude the currently-playing track (it's loaded by the
|
||||
// player itself) and walk `window` tracks forward.
|
||||
val firstIdx = (index + 1).coerceAtMost(queue.size)
|
||||
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
|
||||
if (firstIdx > lastIdx) {
|
||||
cancelAllLocked()
|
||||
return
|
||||
}
|
||||
val targets = queue.subList(firstIdx, lastIdx + 1)
|
||||
val targetIds = targets.mapTo(mutableSetOf()) { it.first }
|
||||
// Cancellation always runs so a queue mutation or skip frees
|
||||
// the pipe immediately, even while paused.
|
||||
cancelOutOfWindowLocked(targetIds)
|
||||
if (isPlaying) startInWindowLocked(targets)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel jobs for tracks that have slid out of the window.
|
||||
activeJobs.entries
|
||||
.filter { it.key !in targetIds }
|
||||
.toList()
|
||||
.forEach { (id, job) ->
|
||||
job.cancel()
|
||||
activeJobs.remove(id)
|
||||
}
|
||||
private fun computeTargets(
|
||||
queue: List<Pair<String, String>>,
|
||||
index: Int,
|
||||
window: Int,
|
||||
): List<Pair<String, String>> {
|
||||
// Exclude the currently-playing track (it's loaded by the player
|
||||
// itself) and walk `window` tracks forward.
|
||||
val firstIdx = index + 1
|
||||
val lastIdx = (index + window).coerceAtMost(queue.size - 1)
|
||||
val isValid = index >= 0 && queue.isNotEmpty() && window > 0 && firstIdx <= lastIdx
|
||||
return if (isValid) queue.subList(firstIdx, lastIdx + 1) else emptyList()
|
||||
}
|
||||
|
||||
// Start prefetches for new arrivals. Skip blank URLs (these
|
||||
// come from minimal TrackRefs synthesized from playlist rows
|
||||
// when the upstream track was removed from the library).
|
||||
for ((trackId, streamUrl) in targets) {
|
||||
if (trackId in activeJobs || streamUrl.isBlank()) continue
|
||||
val job = scope.launch(Dispatchers.IO) {
|
||||
runCatching { prefetchOne(trackId, streamUrl) }
|
||||
mutex.withLock { activeJobs.remove(trackId) }
|
||||
}
|
||||
activeJobs[trackId] = job
|
||||
private fun cancelOutOfWindowLocked(targetIds: Set<String>) {
|
||||
activeJobs.entries
|
||||
.filter { it.key !in targetIds }
|
||||
.toList()
|
||||
.forEach { (id, job) ->
|
||||
job.cancel()
|
||||
activeJobs.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startInWindowLocked(targets: List<Pair<String, String>>) {
|
||||
// Skip blank URLs (these come from minimal TrackRefs synthesized
|
||||
// from playlist rows when the upstream track was removed from
|
||||
// the library).
|
||||
for ((trackId, streamUrl) in targets) {
|
||||
if (trackId in activeJobs || streamUrl.isBlank()) continue
|
||||
val job = scope.launch(Dispatchers.IO) {
|
||||
runCatching { prefetchOne(trackId, streamUrl) }
|
||||
mutex.withLock { activeJobs.remove(trackId) }
|
||||
}
|
||||
activeJobs[trackId] = job
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* MediaSession callback that adds the like/heart custom command to
|
||||
* every connecting controller (notification, lock screen, Pixel Watch,
|
||||
* Android Auto) and routes taps through [LikesRepository.toggleLike]
|
||||
* so they inherit the offline-resilient MutationQueue path.
|
||||
*
|
||||
* The [onConnect] grant is REQUIRED — without it the button is
|
||||
* silently invisible on some surfaces (Media3 issue #2679). See the
|
||||
* spec at docs/superpowers/specs/2026-06-02-android-media3-like-button-design.md.
|
||||
*
|
||||
* Suspend work in [onCustomCommand] is launched on [scope] (a
|
||||
* service-lifetime scope) so the callback returns synchronously and
|
||||
* the controller does not block on Room + REST. State updates flow
|
||||
* back through [LikesRepository.observeIsLiked] so the icon refreshes
|
||||
* via the like-state job in [MinstrelPlayerService].
|
||||
*/
|
||||
class LikeMediaCallback(
|
||||
private val likes: LikesRepository,
|
||||
private val scope: CoroutineScope,
|
||||
) : MediaSession.Callback {
|
||||
|
||||
override fun onConnect(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
): MediaSession.ConnectionResult {
|
||||
val grants = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS
|
||||
.buildUpon()
|
||||
.add(SessionCommand(CMD_TOGGLE_LIKE, Bundle.EMPTY))
|
||||
.build()
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(grants)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onCustomCommand(
|
||||
session: MediaSession,
|
||||
controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand,
|
||||
args: Bundle,
|
||||
): ListenableFuture<SessionResult> {
|
||||
val code = if (customCommand.customAction == CMD_TOGGLE_LIKE) {
|
||||
launchToggleForCurrent(session)
|
||||
} else {
|
||||
SessionResult.RESULT_ERROR_NOT_SUPPORTED
|
||||
}
|
||||
return Futures.immediateFuture(SessionResult(code))
|
||||
}
|
||||
|
||||
private fun launchToggleForCurrent(session: MediaSession): Int {
|
||||
val mediaId = session.player.currentMediaItem?.mediaId
|
||||
?: return SessionResult.RESULT_INFO_SKIPPED
|
||||
scope.launch {
|
||||
val current = likes.observeIsLiked(ENTITY_TRACK, mediaId).first()
|
||||
likes.toggleLike(ENTITY_TRACK, mediaId, !current)
|
||||
}
|
||||
return SessionResult.RESULT_SUCCESS
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Custom-action key for the like/heart button. Namespaced so
|
||||
* future custom commands (output picker, etc.) don't collide. */
|
||||
const val CMD_TOGGLE_LIKE: String = "minstrel.toggle_like"
|
||||
}
|
||||
}
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
@file:Suppress("TooManyFunctions") // Mirrors Player surface: ~16 methods is the API.
|
||||
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.SystemClock
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.selects.onTimeout
|
||||
import kotlinx.coroutines.selects.select
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Integration point for UPnP transport parity. Wraps the local
|
||||
* ExoPlayer; every transport method either forwards (local route
|
||||
* active -- the default) or translates into AVTransport SOAP +
|
||||
* [RemotePlayerState] updates (UPnP route active).
|
||||
*
|
||||
* Created inside [MinstrelPlayerService]; runs on the service's main
|
||||
* looper. Network SOAP calls fire on [Dispatchers.IO]. While UPnP is
|
||||
* active, the MediaSession's reads of [Player.isPlaying] and
|
||||
* [Player.getCurrentPosition] pull from [RemotePlayerState]; the
|
||||
* wrapped ExoPlayer stays paused at the position it had when the
|
||||
* route was selected.
|
||||
*
|
||||
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
|
||||
* factory wraps that callback into a SharedFlow consumed by the
|
||||
* NowPlaying surface as a snackbar.
|
||||
*
|
||||
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||
* advance natively. PollLoop syncs the local cursor by comparing the
|
||||
* 1-based Track index from GetPositionInfo.
|
||||
*/
|
||||
class MinstrelForwardingPlayer(
|
||||
private val delegate: Player,
|
||||
private val holder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val onDrop: (routeName: String) -> Unit,
|
||||
) : ForwardingPlayer(delegate) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val handler = Handler(delegate.applicationLooper)
|
||||
private var pollJob: Job? = null
|
||||
|
||||
// Tracks consecutive non-PLAYING poll observations so a single transient
|
||||
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
|
||||
// flip the play/pause button. Manual pause still feels instant because it
|
||||
// bypasses the poll entirely via applyTransportPaused().
|
||||
@Volatile private var nonPlayingPollStreak = 0
|
||||
|
||||
// Wall-clock of the most-recent within-track seek we issued to Sonos.
|
||||
// pollOnce uses this to suppress position overwrites for SEEK_ACK_WINDOW_MS
|
||||
// -- Sonos can take 1-2s to apply a Seek, and a poll landing inside that
|
||||
// window reports the *old* position. Without the lockout the scrubber
|
||||
// visibly jumps backwards immediately after a drag, then forwards again.
|
||||
@Volatile private var lastSeekIssuedAtMs: Long = 0L
|
||||
|
||||
// Wake channel for the poll loop. requestImmediatePoll() trySend's a Unit;
|
||||
// pollLoop's select{} races the delay against this channel so the next
|
||||
// pollOnce can fire immediately instead of waiting up to POLL_INTERVAL_MS.
|
||||
// Used on activity resume (ProcessLifecycleOwner.ON_RESUME) so the UI
|
||||
// catches up to Sonos within RTT rather than the full poll cadence.
|
||||
// CONFLATED so repeated trySend's between polls don't queue up.
|
||||
private val pollTrigger = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
// External Player.Listener registry (separate from super.addListener which
|
||||
// forwards to the wrapped ExoPlayer). The wrapped player is paused with
|
||||
// no audio loaded while UPnP is active, so it never fires events for our
|
||||
// synthesized remote state -- the MediaSession's notification card and
|
||||
// lock-screen scrubber stay frozen on whatever state was last captured
|
||||
// before UPnP took over. We dual-register: super.addListener keeps the
|
||||
// listener attached to the delegate (so local-playback events still
|
||||
// reach it), AND we hold a ref here so we can directly invoke listener
|
||||
// callbacks on remote-state changes. The listener's read of isPlaying /
|
||||
// duration / position then routes through our overrides to remoteState.
|
||||
private val externalListeners = mutableListOf<Player.Listener>()
|
||||
|
||||
@Volatile private var lastNotifiedIsPlaying: Boolean = false
|
||||
@Volatile private var lastNotifiedTrackIdx: Int = -1
|
||||
|
||||
private val lifecycleObserver = object : DefaultLifecycleObserver {
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
pollTrigger.trySend(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
holder.active.collect { active -> onActiveChanged(active) }
|
||||
}
|
||||
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
||||
// addObserver requires it. The observer just trySend's to the channel.
|
||||
handler.post {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRemote(): Boolean = holder.active.value != null
|
||||
|
||||
/**
|
||||
* Returns true when selectUpnp has marked a UPnP route as the intended
|
||||
* target but loadQueueOnSonos hasn't yet wired ActiveUpnp. During this
|
||||
* window we drop transport commands silently -- they would hit Sonos's
|
||||
* stale state from a prior session and trigger restarts.
|
||||
*/
|
||||
private fun isLoadingUpnp(): Boolean =
|
||||
holder.target.value != null && holder.active.value == null
|
||||
|
||||
// ─── setMediaItems intercepts ──────────────────────────────────────
|
||||
// When PlayerController.setQueue replaces the queue while Sonos is the
|
||||
// active route, the wrapped delegate's queue gets the new items but
|
||||
// Sonos's native queue still holds the OLD tracks -- and the play()
|
||||
// that PlayerController fires immediately after setMediaItems would
|
||||
// resume the old Sonos queue (user reported on-device: "player view
|
||||
// updates but Sonos queue does not"). We clear active + set target
|
||||
// synchronously here so the next play() in the same IPC sequence
|
||||
// drops via isLoadingUpnp() = true; the OutputPickerController
|
||||
// observes the uiState.queue change and runs the resync (re-clears
|
||||
// Sonos's native queue + AddURIToQueue the new tracks + Play).
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>) {
|
||||
super.setMediaItems(mediaItems)
|
||||
markPendingResyncIfRemote()
|
||||
}
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
|
||||
super.setMediaItems(mediaItems, resetPosition)
|
||||
markPendingResyncIfRemote()
|
||||
}
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
|
||||
super.setMediaItems(mediaItems, startIndex, startPositionMs)
|
||||
markPendingResyncIfRemote()
|
||||
}
|
||||
|
||||
private fun markPendingResyncIfRemote() {
|
||||
val wasActive = holder.active.value ?: return
|
||||
Timber.w(
|
||||
"setMediaItems while UPnP active (%s) -- marking pending resync",
|
||||
wasActive.routeName,
|
||||
)
|
||||
holder.set(null)
|
||||
holder.setTarget(wasActive.routeId)
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.play() dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w("ForwardingPlayer.play() active=%s", active?.routeName)
|
||||
if (active == null) {
|
||||
super.play()
|
||||
} else {
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.play() }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPlaying()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.pause() dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w("ForwardingPlayer.pause() active=%s", active?.routeName)
|
||||
if (active == null) {
|
||||
super.pause()
|
||||
} else {
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.pause() }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPaused()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.seekTo(positionMs) dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w("ForwardingPlayer.seekTo(%dms) active=%s", positionMs, active?.routeName)
|
||||
if (active == null) {
|
||||
super.seekTo(positionMs)
|
||||
} else {
|
||||
lastSeekIssuedAtMs = SystemClock.elapsedRealtime()
|
||||
remoteState.applyPositionInfo(
|
||||
positionMs = positionMs,
|
||||
durationMs = remoteState.durationMs,
|
||||
trackUri = remoteState.currentTrackUri,
|
||||
trackNumber = remoteState.trackNumber,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.seek(positionMs) }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget-driven track change (user taps a track in the queue widget).
|
||||
* Seeks Sonos to the correct queue slot, then seeks within-track if
|
||||
* [positionMs] is non-zero.
|
||||
*/
|
||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.seekTo(idx, positionMs) dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w(
|
||||
"ForwardingPlayer.seekTo(idx=%d, %dms) active=%s",
|
||||
mediaItemIndex, positionMs, active?.routeName,
|
||||
)
|
||||
if (active == null) {
|
||||
super.seekTo(mediaItemIndex, positionMs)
|
||||
return
|
||||
}
|
||||
super.seekTo(mediaItemIndex, positionMs)
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching {
|
||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
}
|
||||
}.onFailure { handleSoapFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekToNextMediaItem() {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.seekToNextMediaItem() dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w("ForwardingPlayer.seekToNextMediaItem() active=%s", active?.routeName)
|
||||
if (active == null) {
|
||||
super.seekToNextMediaItem()
|
||||
return
|
||||
}
|
||||
// Super first for immediate local cursor advance (UI feedback);
|
||||
// then delegate to Sonos Next. PollLoop reconciles cursor via
|
||||
// Track index if they diverge.
|
||||
super.seekToNextMediaItem()
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.next() }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekToPreviousMediaItem() {
|
||||
if (isLoadingUpnp()) {
|
||||
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() dropped -- UPnP loading")
|
||||
return
|
||||
}
|
||||
val active = holder.active.value
|
||||
Timber.w("ForwardingPlayer.seekToPreviousMediaItem() active=%s", active?.routeName)
|
||||
if (active == null) {
|
||||
super.seekToPreviousMediaItem()
|
||||
return
|
||||
}
|
||||
// Super first for immediate local cursor advance (UI feedback);
|
||||
// then delegate to Sonos Previous. PollLoop reconciles.
|
||||
super.seekToPreviousMediaItem()
|
||||
remoteState.beginPendingTransport(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.previous() }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCurrentPosition(): Long =
|
||||
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
||||
|
||||
override fun getDuration(): Long =
|
||||
if (isRemote()) remoteState.durationMs else super.getDuration()
|
||||
|
||||
override fun isPlaying(): Boolean =
|
||||
if (isRemote()) remoteState.isPlaying else super.isPlaying()
|
||||
|
||||
override fun getPlaybackState(): Int =
|
||||
if (isRemote()) Player.STATE_READY else super.getPlaybackState()
|
||||
|
||||
// Mirror remote state so any consumer that gates on playWhenReady --
|
||||
// notably MediaSessionService's foreground-keepalive checks and our own
|
||||
// onTaskRemoved -- sees the remote renderer as the source of truth.
|
||||
// Without this, swiping the app away with Sonos playing would stop the
|
||||
// service, kill the poll loop, and leave Sonos orphaned.
|
||||
override fun getPlayWhenReady(): Boolean =
|
||||
if (isRemote()) remoteState.isPlaying else super.getPlayWhenReady()
|
||||
|
||||
override fun addListener(listener: Player.Listener) {
|
||||
super.addListener(listener)
|
||||
synchronized(externalListeners) { externalListeners.add(listener) }
|
||||
}
|
||||
|
||||
override fun removeListener(listener: Player.Listener) {
|
||||
super.removeListener(listener)
|
||||
synchronized(externalListeners) { externalListeners.remove(listener) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct-invoke the externally-registered Player.Listeners so the
|
||||
* MediaSession's PlaybackState publisher (notification card, lock-screen
|
||||
* scrubber, BT/AVRCP, Auto, Wear OS tile) re-reads our overridden state.
|
||||
* The listeners then query isPlaying / getDuration / getCurrentPosition,
|
||||
* all of which route through to remoteState while UPnP is active.
|
||||
*
|
||||
* Posted to the player's application looper because Player.Listener
|
||||
* callbacks contract on the application thread.
|
||||
*/
|
||||
private fun notifyRemoteStateChanged() {
|
||||
if (!isRemote()) return
|
||||
val playing = remoteState.isPlaying
|
||||
val trackIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
|
||||
val isPlayingChanged = playing != lastNotifiedIsPlaying
|
||||
val trackChanged = trackIdx != lastNotifiedTrackIdx
|
||||
if (!isPlayingChanged && !trackChanged) return
|
||||
lastNotifiedIsPlaying = playing
|
||||
lastNotifiedTrackIdx = trackIdx
|
||||
val snapshot = synchronized(externalListeners) { externalListeners.toList() }
|
||||
handler.post {
|
||||
for (l in snapshot) {
|
||||
if (isPlayingChanged) {
|
||||
l.onIsPlayingChanged(playing)
|
||||
l.onPlaybackStateChanged(Player.STATE_READY)
|
||||
}
|
||||
if (trackChanged) {
|
||||
val item = if (trackIdx < delegate.mediaItemCount) {
|
||||
delegate.getMediaItemAt(trackIdx)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
l.onMediaItemTransition(item, Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
pollJob?.cancel()
|
||||
scope.cancel()
|
||||
handler.post {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
|
||||
}
|
||||
super.release()
|
||||
}
|
||||
|
||||
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
|
||||
pollJob?.cancel()
|
||||
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
|
||||
remoteState.applyError(t)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
}
|
||||
|
||||
private fun onActiveChanged(active: ActiveUpnp?) {
|
||||
pollJob?.cancel()
|
||||
nonPlayingPollStreak = 0
|
||||
// Reset notify cache so the first poll after a route flip republishes
|
||||
// playing/track state to the MediaSession even if it happens to match
|
||||
// the prior session's values numerically.
|
||||
lastNotifiedIsPlaying = false
|
||||
lastNotifiedTrackIdx = -1
|
||||
if (active != null) {
|
||||
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
||||
// Pause the wrapped ExoPlayer so we are not playing local audio
|
||||
// simultaneously with the remote renderer. handler.post targets the
|
||||
// application looper, so this runs on the same thread that processes
|
||||
// our override calls -- no race with the pause() override branching
|
||||
// to SOAP (holder.active is already non-null by the time this post
|
||||
// fires, but delegate.pause() bypasses the override entirely).
|
||||
handler.post { delegate.pause() }
|
||||
pollJob = scope.launch { pollLoop(active) }
|
||||
} else {
|
||||
remoteState.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||
val outcome = runCatching { pollOnce(active) }
|
||||
if (outcome.isSuccess) {
|
||||
remoteState.recordPollSuccess()
|
||||
} else if (remoteState.recordPollFailure()) {
|
||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
return
|
||||
}
|
||||
// Race the normal cadence against any external wake (activity
|
||||
// resume). Whichever wins continues to the next pollOnce.
|
||||
select<Unit> {
|
||||
onTimeout(POLL_INTERVAL_MS) {}
|
||||
pollTrigger.onReceive {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll tick: read position + transport state from Sonos, apply to
|
||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||
* index when not in queue load.
|
||||
*
|
||||
* Cursor sync is gated on `holder.target == null` (= not loading)
|
||||
* because during load Sonos reports Track=1 while we're still
|
||||
* appending, and syncing would race the SetAV+Seek that lands
|
||||
* after. Outside load, forward sync catches Sonos auto-advances
|
||||
* (queue end-of-track), Sonos-app driven Next presses, and any
|
||||
* drift after a brief poll-failure burst that didn't trip the
|
||||
* drop threshold. Forward-only because a Next override we just
|
||||
* issued can race with a poll still reporting the prior Track --
|
||||
* the next poll catches up safely.
|
||||
*/
|
||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||
val info = active.avTransport.getPositionInfo()
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
|
||||
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
|
||||
// Inside the seek-ack window, keep the optimistic position we wrote in
|
||||
// seekTo -- the poll's reported position is stale until Sonos finishes
|
||||
// processing the Seek SOAP. Other fields still refresh from the poll.
|
||||
remoteState.applyPositionInfo(
|
||||
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
|
||||
durationMs = info.trackDurationMs,
|
||||
trackUri = info.trackUri,
|
||||
trackNumber = info.track,
|
||||
)
|
||||
maybeSyncLocalCursor(info.track)
|
||||
val transport = active.avTransport.getTransportInfo()
|
||||
when (transport.state) {
|
||||
TransportState.PLAYING -> {
|
||||
nonPlayingPollStreak = 0
|
||||
remoteState.applyTransportPlaying()
|
||||
}
|
||||
TransportState.PAUSED -> {
|
||||
nonPlayingPollStreak += 1
|
||||
if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
|
||||
remoteState.applyTransportPaused()
|
||||
}
|
||||
}
|
||||
TransportState.STOPPED -> {
|
||||
nonPlayingPollStreak += 1
|
||||
if (nonPlayingPollStreak >= NON_PLAYING_CONFIRM) {
|
||||
remoteState.applyTransportStopped()
|
||||
}
|
||||
}
|
||||
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
|
||||
}
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
|
||||
private fun maybeSyncLocalCursor(sonosTrack: Int) {
|
||||
if (holder.target.value != null) return
|
||||
if (sonosTrack <= 0) return
|
||||
val sonosIdx = sonosTrack - 1
|
||||
handler.post {
|
||||
val localIdx = delegate.currentMediaItemIndex
|
||||
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
|
||||
Timber.w(
|
||||
"UPnP cursor catch-up: local=%d -> sonos=%d",
|
||||
localIdx, sonosIdx,
|
||||
)
|
||||
delegate.seekTo(sonosIdx, 0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val POLL_INTERVAL_MS = 1_000L
|
||||
const val NON_PLAYING_CONFIRM = 2
|
||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
||||
// Safety upper bound on how long the polling tick will wait for
|
||||
// Sonos to ack a user transport. The common case clears event-driven
|
||||
// when Sonos's reported Track matches the wrapped player; this only
|
||||
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
||||
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
+110
-5
@@ -2,11 +2,29 @@ package com.fabledsword.minstrel.player
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import androidx.media3.session.SessionCommand
|
||||
import com.fabledsword.minstrel.MainActivity
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository.Companion.ENTITY_TRACK
|
||||
import com.google.common.collect.ImmutableList
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -20,29 +38,50 @@ import javax.inject.Inject
|
||||
* MediaButtonReceiver is registered automatically by the library, no
|
||||
* manual receiver class needed.
|
||||
*
|
||||
* The session advertises a single custom CommandButton — the like/heart
|
||||
* toggle. [LikeMediaCallback] grants the command in `onConnect` and
|
||||
* routes taps through [LikesRepository.toggleLike] so they inherit the
|
||||
* offline-resilient MutationQueue path. The icon mirrors server state:
|
||||
* a service-scoped coroutine collects `currentMediaItem × isLiked` and
|
||||
* rebuilds the preferences list on each emission so a cross-device
|
||||
* like (web tap) flips the notification heart automatically.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - onCreate: build the ExoPlayer + MediaSession once.
|
||||
* - onCreate: build the ExoPlayer + MediaSession once, attach the
|
||||
* callback, set initial preferences, launch the like-state job.
|
||||
* - onGetSession: return the live session to any binding controller
|
||||
* (system UI media card, Wear OS companion, MediaController3 clients).
|
||||
* - onTaskRemoved: if the user swipes the app away, keep playing when
|
||||
* audio is active (standard media-app behavior — music shouldn't
|
||||
* die because the app left recents); otherwise stop the service so
|
||||
* the lingering notification clears.
|
||||
* - onDestroy: release the session + player.
|
||||
* - onDestroy: cancel the service scope, then release the session +
|
||||
* player. Cancellation comes first so the like-state job doesn't
|
||||
* touch a released session.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MinstrelPlayerService : MediaSessionService() {
|
||||
|
||||
@Inject lateinit var playerFactory: PlayerFactory
|
||||
|
||||
@Inject lateinit var likesRepository: LikesRepository
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val player = playerFactory.build()
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
val player: Player = playerFactory.build()
|
||||
val callback = LikeMediaCallback(likesRepository, serviceScope)
|
||||
val session = MediaSession.Builder(this, player)
|
||||
.setSessionActivity(buildNowPlayingPendingIntent())
|
||||
.setCallback(callback)
|
||||
.setBitmapLoader(playerFactory.buildBitmapLoader())
|
||||
.setMediaButtonPreferences(ImmutableList.of(buildLikeButton(isLiked = false)))
|
||||
.build()
|
||||
mediaSession = session
|
||||
serviceScope.launch { observeLikeState(session, player) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,12 +111,77 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the like/heart CommandButton. Icon flips between
|
||||
* ICON_HEART_FILLED and ICON_HEART_UNFILLED to mirror server-side
|
||||
* like state. The session command is the same in both states so
|
||||
* the callback's onCustomCommand handles them identically (it
|
||||
* inverts the current observed state regardless of which icon was
|
||||
* tapped).
|
||||
*/
|
||||
private fun buildLikeButton(isLiked: Boolean): CommandButton {
|
||||
val icon = if (isLiked) {
|
||||
CommandButton.ICON_HEART_FILLED
|
||||
} else {
|
||||
CommandButton.ICON_HEART_UNFILLED
|
||||
}
|
||||
return CommandButton.Builder(icon)
|
||||
.setDisplayName(if (isLiked) "Unlike" else "Like")
|
||||
.setSessionCommand(SessionCommand(LikeMediaCallback.CMD_TOGGLE_LIKE, Bundle.EMPTY))
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect player.currentMediaItem changes (via a Player.Listener
|
||||
* lifted into a Flow) and, for each non-null mediaId, observe its
|
||||
* liked state. On every emission, rebuild the session's media
|
||||
* button preferences with the icon flipped accordingly.
|
||||
* flatMapLatest cancels the previous track's subscription so we
|
||||
* never leak Flows across track transitions.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private suspend fun observeLikeState(session: MediaSession, player: Player) {
|
||||
currentMediaIdFlow(player)
|
||||
.flatMapLatest { mediaId ->
|
||||
if (mediaId == null) {
|
||||
flowOf(false)
|
||||
} else {
|
||||
likesRepository.observeIsLiked(ENTITY_TRACK, mediaId)
|
||||
}
|
||||
}
|
||||
.collect { isLiked ->
|
||||
session.setMediaButtonPreferences(
|
||||
ImmutableList.of(buildLikeButton(isLiked = isLiked)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift Player.currentMediaItem changes into a Flow. Emits the
|
||||
* current mediaId on subscription so the initial icon state is
|
||||
* correct on the first frame the controller renders (no
|
||||
* unfilled-then-flips flicker).
|
||||
*/
|
||||
private fun currentMediaIdFlow(player: Player) = callbackFlow<String?> {
|
||||
val listener = object : Player.Listener {
|
||||
override fun onMediaItemTransition(item: MediaItem?, reason: Int) {
|
||||
trySend(item?.mediaId)
|
||||
}
|
||||
}
|
||||
player.addListener(listener)
|
||||
awaitClose { player.removeListener(listener) }
|
||||
}.onStart { emit(player.currentMediaItem?.mediaId) }
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
|
||||
mediaSession
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val player = mediaSession?.player ?: return super.onTaskRemoved(rootIntent)
|
||||
val activelyPlaying = player.playWhenReady && player.playbackState != Player.STATE_ENDED
|
||||
// player.isPlaying is overridden on MinstrelForwardingPlayer to return
|
||||
// remoteState.isPlaying while UPnP is active, so a swipe-away with
|
||||
// Sonos playing keeps the service (and its UPnP poll loop) alive.
|
||||
val activelyPlaying = player.isPlaying ||
|
||||
(player.playWhenReady && player.playbackState != Player.STATE_ENDED)
|
||||
if (!activelyPlaying) {
|
||||
stopSelf()
|
||||
}
|
||||
@@ -85,6 +189,7 @@ class MinstrelPlayerService : MediaSessionService() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
serviceScope.cancel()
|
||||
mediaSession?.run {
|
||||
player.release()
|
||||
release()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
|
||||
/**
|
||||
* DataSource wrapper that fails the network read immediately when
|
||||
* [ServerHealthController] reports a non-Healthy state. CacheDataSource only
|
||||
* calls this upstream factory on cache misses, so playback of cached audio is
|
||||
* unaffected -- only "tap a non-cached track while offline" hits this branch
|
||||
* and gets a fast, meaningful error instead of a multi-second network timeout
|
||||
* (which then surfaced as a silent decode failure to the user).
|
||||
*
|
||||
* Wrapping rather than substituting the OkHttp data source lets the cache
|
||||
* write path remain intact for when health returns and we DO want to fetch:
|
||||
* we keep the same upstream all the time, just gate `open()`.
|
||||
*/
|
||||
class OfflineGatedDataSource(
|
||||
private val delegate: DataSource,
|
||||
private val health: ServerHealthController,
|
||||
) : DataSource {
|
||||
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
when (health.state.value) {
|
||||
ServerHealth.Offline -> throw OfflineException(
|
||||
"Track not in the on-device cache and the device is offline.",
|
||||
)
|
||||
ServerHealth.ServerDown -> throw OfflineException(
|
||||
"Track not in the on-device cache and the Minstrel server is unreachable.",
|
||||
)
|
||||
ServerHealth.Healthy -> Unit
|
||||
}
|
||||
return delegate.open(dataSpec)
|
||||
}
|
||||
|
||||
override fun close() = delegate.close()
|
||||
override fun getUri() = delegate.uri
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
|
||||
delegate.read(buffer, offset, length)
|
||||
override fun addTransferListener(transferListener: TransferListener) =
|
||||
delegate.addTransferListener(transferListener)
|
||||
override fun getResponseHeaders() = delegate.responseHeaders
|
||||
}
|
||||
|
||||
class OfflineGatedDataSourceFactory(
|
||||
private val upstream: DataSource.Factory,
|
||||
private val health: ServerHealthController,
|
||||
) : DataSource.Factory {
|
||||
override fun createDataSource(): DataSource =
|
||||
OfflineGatedDataSource(upstream.createDataSource(), health)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals the audio-source error path that the request was denied because the
|
||||
* device is offline / the server is unreachable. ExoPlayer's [androidx.media3
|
||||
* .common.PlaybackException] catches it via [InterruptedIOException]'s
|
||||
* `IOException` ancestor and surfaces it as a SOURCE error, which then flows
|
||||
* through the existing [PlaybackErrorReporter] -> snackbar path.
|
||||
*/
|
||||
class OfflineException(message: String) : IOException(message)
|
||||
@@ -3,6 +3,10 @@ package com.fabledsword.minstrel.player
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
@@ -18,8 +22,10 @@ import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -57,7 +63,17 @@ class PlayerController @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val radio: RadioController,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
) {
|
||||
|
||||
/**
|
||||
* UPnP drop events surfaced from [PlayerFactory.dropEvents]. NowPlaying
|
||||
* collects this into its snackbar host so a transport / poll failure
|
||||
* during UPnP playback shows "Disconnected from <name>" to the user.
|
||||
*/
|
||||
val dropEvents: SharedFlow<String> = playerFactory.dropEvents
|
||||
private val sessionToken =
|
||||
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
|
||||
|
||||
@@ -128,11 +144,24 @@ class PlayerController @Inject constructor(
|
||||
|
||||
// ── Transport (no-op until the controller is connected) ──────────────
|
||||
|
||||
fun play() { mediaController?.play() }
|
||||
fun pause() { mediaController?.pause() }
|
||||
fun seekTo(positionMs: Long) { mediaController?.seekTo(positionMs) }
|
||||
fun skipToNext() { mediaController?.seekToNextMediaItem() }
|
||||
fun skipToPrevious() { mediaController?.seekToPreviousMediaItem() }
|
||||
// Each transport call must run on the MediaController's
|
||||
// applicationLooper; calling from a background coroutine throws
|
||||
// IllegalStateException (see PlayerController.setQueue's note).
|
||||
// UI tap handlers are already on Main so the in-place branch hits;
|
||||
// the background path only fires for cross-thread callers like
|
||||
// OutputPickerController.selectUpnp (which calls pause() after
|
||||
// handing playback off to a UPnP renderer).
|
||||
fun play() { mediaController?.let { runOnControllerThread(it) { it.play() } } }
|
||||
fun pause() { mediaController?.let { runOnControllerThread(it) { it.pause() } } }
|
||||
fun seekTo(positionMs: Long) {
|
||||
mediaController?.let { runOnControllerThread(it) { it.seekTo(positionMs) } }
|
||||
}
|
||||
fun skipToNext() {
|
||||
mediaController?.let { runOnControllerThread(it) { it.seekToNextMediaItem() } }
|
||||
}
|
||||
fun skipToPrevious() {
|
||||
mediaController?.let { runOnControllerThread(it) { it.seekToPreviousMediaItem() } }
|
||||
}
|
||||
|
||||
/** Flip shuffle on/off. Media3 emits onEvents → uiState reflects. */
|
||||
fun toggleShuffle() {
|
||||
@@ -187,9 +216,26 @@ class PlayerController @Inject constructor(
|
||||
val controller = mediaController ?: return
|
||||
queueRefs = tracks
|
||||
val items = tracks.map { it.toMediaItem(source) }
|
||||
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
|
||||
controller.prepare()
|
||||
if (autoplay) controller.play()
|
||||
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
||||
// context after awaitReady() unblocks (ResumeController launches
|
||||
// on Dispatchers.Default by the time it reaches us). MediaController
|
||||
// enforces application-thread access and throws
|
||||
// IllegalStateException otherwise — post to its applicationLooper
|
||||
// if we're already there, run directly to avoid the re-dispatch
|
||||
// latency UI callers depend on.
|
||||
runOnControllerThread(controller) {
|
||||
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
|
||||
controller.prepare()
|
||||
if (autoplay) controller.play()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runOnControllerThread(controller: MediaController, block: () -> Unit) {
|
||||
if (Looper.myLooper() == controller.applicationLooper) {
|
||||
block()
|
||||
} else {
|
||||
Handler(controller.applicationLooper).post(block)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,7 +326,15 @@ class PlayerController @Inject constructor(
|
||||
* and advance past the dead track. Otherwise no-op.
|
||||
*/
|
||||
private fun handleZeroDurationIfNeeded(controller: MediaController, idx: Int) {
|
||||
val current = queueRefs.getOrNull(idx) ?: return
|
||||
// Skip during UPnP playback (active) AND during the activation load
|
||||
// window (target set, active not yet wired). ExoPlayer is intentionally
|
||||
// paused throughout both windows so its STATE_READY duration is always
|
||||
// 0 / TIME_UNSET -- without this guard we rapid-advance through the
|
||||
// entire local queue (and spam /api/playback-errors).
|
||||
val current = queueRefs.getOrNull(idx)
|
||||
val upnpEngaged = activeUpnpHolder.active.value != null ||
|
||||
activeUpnpHolder.target.value != null
|
||||
if (current == null || upnpEngaged) return
|
||||
val duration = controller.duration
|
||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||
if (!isZeroDuration) return
|
||||
@@ -322,6 +376,19 @@ class PlayerController @Inject constructor(
|
||||
// awaitReady, the Player.Listener is wired too.
|
||||
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
|
||||
startPositionPolling(controller)
|
||||
// Keep isUpnpLoading current between player-event fires: holder state
|
||||
// changes (setTarget / set(active)) are independent of Media3 events, so
|
||||
// onEvents alone would lag behind by up to one event cycle. This collector
|
||||
// runs for the process lifetime alongside the position poller.
|
||||
scope.launch {
|
||||
combine(
|
||||
activeUpnpHolder.target,
|
||||
activeUpnpHolder.active,
|
||||
) { target, active -> target != null && active == null }
|
||||
.collect { isLoading ->
|
||||
uiStateInternal.value = uiStateInternal.value.copy(isUpnpLoading = isLoading)
|
||||
}
|
||||
}
|
||||
controller.addListener(
|
||||
object : Player.Listener {
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
@@ -345,6 +412,11 @@ class PlayerController @Inject constructor(
|
||||
// Reset the per-item evaluation guard so the new
|
||||
// item's STATE_READY transition gets a fresh check.
|
||||
lastEvaluatedItemIndex = -1
|
||||
// The track flipped -- re-anchor the position interpolator
|
||||
// so the next polling tick treats the new track's
|
||||
// remoteState.positionMs as fresh rather than carrying the
|
||||
// old anchor + elapsed forward.
|
||||
lastSeenRemotePositionMs = -1L
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
@@ -362,15 +434,38 @@ class PlayerController @Inject constructor(
|
||||
?.mediaMetadata
|
||||
?.extras
|
||||
?.getString(MINSTREL_SOURCE_KEY)
|
||||
val isUpnpLoading = activeUpnpHolder.target.value != null &&
|
||||
activeUpnpHolder.active.value == null
|
||||
// When UPnP is active, the wrapped ExoPlayer is paused with
|
||||
// no real audio loaded -- player.duration / isPlaying /
|
||||
// currentPosition all reflect that. Read from remoteState
|
||||
// instead so an onEvents fire (e.g. activity resume) doesn't
|
||||
// clobber the UI with zeros. Duration falls back to the
|
||||
// wrapped player's value when Sonos hasn't reported one yet
|
||||
// (pre-first-poll window, or Sonos still buffering) -- the
|
||||
// wrapped ExoPlayer was prepared with the same MediaItem so
|
||||
// it knows the real duration before any SOAP poll lands.
|
||||
val upnpActive = activeUpnpHolder.active.value != null
|
||||
uiStateInternal.value =
|
||||
PlayerUiState(
|
||||
currentTrack = current,
|
||||
queue = queueRefs,
|
||||
queueIndex = idx,
|
||||
isPlaying = player.isPlaying,
|
||||
isBuffering = player.playbackState == Player.STATE_BUFFERING,
|
||||
positionMs = player.currentPosition.coerceAtLeast(0),
|
||||
durationMs = player.duration.coerceAtLeast(0),
|
||||
isPlaying = if (upnpActive) remoteState.isPlaying else player.isPlaying,
|
||||
isBuffering = !upnpActive &&
|
||||
player.playbackState == Player.STATE_BUFFERING,
|
||||
positionMs = if (upnpActive) {
|
||||
remoteState.positionMs
|
||||
} else {
|
||||
player.currentPosition
|
||||
}.coerceAtLeast(0),
|
||||
durationMs = effectiveDuration(
|
||||
upnpActive,
|
||||
remoteState.durationMs,
|
||||
player.duration,
|
||||
desiredIdx = idx,
|
||||
controllerIdx = idx,
|
||||
),
|
||||
bufferedPositionMs = player.bufferedPosition.coerceAtLeast(0),
|
||||
playbackError = player.playerError?.message,
|
||||
currentSource = source,
|
||||
@@ -380,6 +475,7 @@ class PlayerController @Inject constructor(
|
||||
Player.REPEAT_MODE_ONE -> RepeatMode.ONE
|
||||
else -> RepeatMode.OFF
|
||||
},
|
||||
isUpnpLoading = isUpnpLoading,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -404,15 +500,138 @@ class PlayerController @Inject constructor(
|
||||
scope.launch(Dispatchers.Main.immediate) {
|
||||
while (isActive) {
|
||||
delay(POSITION_POLL_INTERVAL_MS)
|
||||
if (!controller.isPlaying) continue
|
||||
uiStateInternal.value = uiStateInternal.value.copy(
|
||||
positionMs = controller.currentPosition.coerceAtLeast(0),
|
||||
bufferedPositionMs = controller.bufferedPosition.coerceAtLeast(0),
|
||||
)
|
||||
tickPositionPoll(controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One position-polling tick. Owns track-change detection too: when UPnP
|
||||
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
|
||||
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
|
||||
* uiState.queueIndex stuck on the old track even after Sonos has
|
||||
* advanced. So the tick reads Sonos's reported Track as the source of
|
||||
* truth, rebuilds the index/title fields itself, and force-syncs the
|
||||
* wrapped player as defense in depth.
|
||||
*/
|
||||
private fun tickPositionPoll(controller: MediaController) {
|
||||
val upnpActive = activeUpnpHolder.active.value != null
|
||||
resolvePendingTransport(controller, upnpActive)
|
||||
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
|
||||
val effectiveIsPlaying =
|
||||
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
||||
val effectivePosition = if (upnpActive) {
|
||||
interpolatedRemotePosition(effectiveIsPlaying)
|
||||
} else {
|
||||
controller.currentPosition
|
||||
}
|
||||
val desiredIdx = desiredQueueIndex(controller, upnpActive)
|
||||
val current = uiStateInternal.value
|
||||
val newPos = effectivePosition.coerceAtLeast(0)
|
||||
val newDur = effectiveDuration(
|
||||
upnpActive,
|
||||
remoteState.durationMs,
|
||||
controller.duration,
|
||||
desiredIdx = desiredIdx,
|
||||
controllerIdx = controller.currentMediaItemIndex,
|
||||
)
|
||||
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
||||
// Track adjustments are forward-only AND suppressed while a user
|
||||
// transport press is pending Sonos confirmation. Together those keep
|
||||
// either direction of user input from being undone by a stale poll.
|
||||
val trackChanged = !pendingTransport &&
|
||||
desiredIdx > current.queueIndex &&
|
||||
desiredIdx in queueRefs.indices
|
||||
publishTickIfChanged(
|
||||
current, trackChanged, desiredIdx,
|
||||
effectiveIsPlaying, newPos, newDur, newBuf,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-driven primary path: clear pending the moment Sonos's reported
|
||||
* Track matches the wrapped player's index. Safety fallback: clear on
|
||||
* deadline so we don't ignore Sonos's actual state forever if SOAP fails.
|
||||
*/
|
||||
private fun resolvePendingTransport(controller: MediaController, upnpActive: Boolean) {
|
||||
if (!upnpActive || remoteState.pendingTransportDeadlineMs <= 0L) return
|
||||
val sonosIdx = (remoteState.trackNumber - 1).coerceAtLeast(0)
|
||||
val timedOut = SystemClock.elapsedRealtime() > remoteState.pendingTransportDeadlineMs
|
||||
if (sonosIdx == controller.currentMediaItemIndex || timedOut) {
|
||||
remoteState.clearPendingTransport()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
|
||||
private fun publishTickIfChanged(
|
||||
current: PlayerUiState,
|
||||
trackChanged: Boolean,
|
||||
desiredIdx: Int,
|
||||
effectiveIsPlaying: Boolean,
|
||||
newPos: Long,
|
||||
newDur: Long,
|
||||
newBuf: Long,
|
||||
) {
|
||||
val somethingChanged = trackChanged ||
|
||||
current.isPlaying != effectiveIsPlaying ||
|
||||
current.positionMs != newPos ||
|
||||
current.durationMs != newDur
|
||||
if (!somethingChanged) return
|
||||
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
|
||||
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
|
||||
uiStateInternal.value = current.copy(
|
||||
currentTrack = newTrack,
|
||||
queueIndex = newIdx,
|
||||
isPlaying = effectiveIsPlaying,
|
||||
positionMs = newPos,
|
||||
durationMs = newDur,
|
||||
bufferedPositionMs = newBuf,
|
||||
)
|
||||
// Intentionally do NOT call controller.seekTo here. That would route
|
||||
// through MinstrelForwardingPlayer's seekTo override and re-issue
|
||||
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
|
||||
// start of the same track it's already playing, restarting the song.
|
||||
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
|
||||
// delegate.seekTo (which bypasses the override). If it lags briefly,
|
||||
// the next pollOnce catches up; the uiState above already reflects
|
||||
// Sonos's truth for the user.
|
||||
}
|
||||
|
||||
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
|
||||
if (upnpActive) {
|
||||
(remoteState.trackNumber - 1).coerceAtLeast(0)
|
||||
} else {
|
||||
controller.currentMediaItemIndex
|
||||
}
|
||||
|
||||
// ── Remote position interpolation state ──────────────────────────────
|
||||
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
||||
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
||||
// sleep can stall it for many seconds). To keep the scrubber moving
|
||||
// smoothly we anchor each fresh reading + an elapsed-realtime stamp;
|
||||
// between updates we display anchor + elapsed when Sonos is playing.
|
||||
// A real correction lands as soon as the next poll arrives.
|
||||
@Volatile private var lastSeenRemotePositionMs: Long = -1L
|
||||
@Volatile private var positionAnchorMs: Long = 0L
|
||||
@Volatile private var positionAnchorAtRealtimeMs: Long = 0L
|
||||
|
||||
private fun interpolatedRemotePosition(isPlaying: Boolean): Long {
|
||||
val raw = remoteState.positionMs
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (raw != lastSeenRemotePositionMs) {
|
||||
lastSeenRemotePositionMs = raw
|
||||
positionAnchorMs = raw
|
||||
positionAnchorAtRealtimeMs = now
|
||||
}
|
||||
if (!isPlaying) return positionAnchorMs
|
||||
// Cap how far past the last anchor we extrapolate. After
|
||||
// MAX_INTERPOLATION_DRIFT_MS without a poll update, freeze the
|
||||
// displayed position at anchor + cap rather than projecting wildly.
|
||||
// The next successful poll re-anchors and motion resumes.
|
||||
val delta = (now - positionAnchorAtRealtimeMs).coerceAtMost(MAX_INTERPOLATION_DRIFT_MS)
|
||||
return positionAnchorMs + delta
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges Media3's `ListenableFuture<MediaController>.buildAsync()`
|
||||
* to a suspend function without pulling in `kotlinx-coroutines-guava`
|
||||
@@ -448,7 +667,23 @@ class PlayerController @Inject constructor(
|
||||
.setArtist(artistName)
|
||||
.setAlbumTitle(albumTitle)
|
||||
.apply {
|
||||
// Server-known duration -- gives the lock-screen / notification
|
||||
// scrubber a real total even when the wrapped ExoPlayer is
|
||||
// paused under UPnP (it never probes a duration in that state).
|
||||
if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND)
|
||||
if (source != null) setExtras(sourceExtras(source))
|
||||
// Point the notification / lock-screen art at the SAME album
|
||||
// cover the in-app surfaces use (TrackRef.coverUrl ->
|
||||
// /api/albums/{id}/cover). Without this, Media3 falls back to
|
||||
// whatever art is embedded in the stream's tags, which can be a
|
||||
// different image than the server's album cover. Setting
|
||||
// artworkUri here is load-bearing: MediaMetadata.populate()
|
||||
// overwrites artworkUri + artworkData as a pair, so the
|
||||
// MediaItem's URI clears any embedded artworkData ExoPlayer
|
||||
// extracts from the stream -- the cover endpoint wins on both
|
||||
// surfaces. The session's OkHttp-backed BitmapLoader (see
|
||||
// PlayerFactory) is what makes this authed placeholder URL load.
|
||||
if (coverUrl.isNotEmpty()) setArtworkUri(coverUrl.toUri())
|
||||
}
|
||||
.build()
|
||||
// Server's stream_url is a relative path (/api/tracks/{id}/stream);
|
||||
@@ -470,8 +705,40 @@ class PlayerController @Inject constructor(
|
||||
private fun sourceExtras(source: String): Bundle =
|
||||
Bundle().apply { putString(MINSTREL_SOURCE_KEY, source) }
|
||||
|
||||
/**
|
||||
* Duration to surface to the UI. Priority: Sonos's reported duration
|
||||
* (only when UPnP active and non-zero) -> wrapped ExoPlayer's value
|
||||
* (only valid once it's probed the stream) -> TrackRef.durationSec
|
||||
* (always known from the server response). The third tier is what
|
||||
* keeps the scrubber populated when the user taps Sonos before the
|
||||
* wrapped player has had time to probe its own duration -- without
|
||||
* it, both top tiers report 0/TIME_UNSET and the field reads empty
|
||||
* until the first SOAP poll lands.
|
||||
*/
|
||||
@Suppress("ReturnCount") // 3-tier fallback reads cleanest as a ladder of early returns
|
||||
private fun effectiveDuration(
|
||||
upnpActive: Boolean,
|
||||
remoteMs: Long,
|
||||
localMs: Long,
|
||||
desiredIdx: Int,
|
||||
controllerIdx: Int,
|
||||
): Long {
|
||||
if (upnpActive && remoteMs > 0) return remoteMs
|
||||
// Tier 2 (wrapped player's probed duration) only valid when the
|
||||
// wrapped player is on the same track we're trying to show. After a
|
||||
// Sonos natural advance the polling tick updates desiredIdx from
|
||||
// Sonos's truth while controllerIdx is briefly stale -- using the
|
||||
// wrapped player's duration here would surface the old track's
|
||||
// length under the new track's title.
|
||||
if (localMs > 0 && controllerIdx == desiredIdx) return localMs
|
||||
val ref = queueRefs.getOrNull(desiredIdx) ?: return 0
|
||||
return ref.durationSec.toLong() * MS_PER_SECOND
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MINSTREL_SOURCE_KEY: String = "minstrel_source"
|
||||
private const val MS_PER_SECOND = 1_000L
|
||||
private const val MAX_INTERPOLATION_DRIFT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ package com.fabledsword.minstrel.player
|
||||
import android.content.Context
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.BitmapLoader
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSourceBitmapLoader
|
||||
import androidx.media3.datasource.cache.CacheDataSink
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
@@ -11,15 +14,21 @@ import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.session.CacheBitmapLoader
|
||||
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Builds the process-singleton ExoPlayer with our shared OkHttp +
|
||||
* Builds the process-singleton player with our shared OkHttp +
|
||||
* SimpleCache chain. The MinstrelPlayerService (Phase 6.2) calls
|
||||
* `build()` once during onCreate.
|
||||
*
|
||||
@@ -31,12 +40,22 @@ import javax.inject.Singleton
|
||||
* (sizeBytes cap = rollingCap); our policy layer in the worker layers
|
||||
* the 2-bucket protection on top by feeding `removeSpan` only for
|
||||
* unprotected tracks.
|
||||
*
|
||||
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
|
||||
* ExoPlayer. When the UPnP route drops (3 consecutive poll failures or
|
||||
* a SOAP failure), [dropEvents] emits the route name so the NowPlaying
|
||||
* surface can show a snackbar. The MutableSharedFlow uses DROP_OLDEST
|
||||
* with capacity=1 so a burst of failures during a single tear-down
|
||||
* surfaces as one event rather than queueing N.
|
||||
*/
|
||||
@Singleton
|
||||
class PlayerFactory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val cacheConfig: CacheConfig,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val serverHealth: com.fabledsword.minstrel.connectivity.ServerHealthController,
|
||||
) {
|
||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||
|
||||
@@ -46,11 +65,36 @@ class PlayerFactory @Inject constructor(
|
||||
StandaloneDatabaseProvider(context),
|
||||
)
|
||||
|
||||
fun build(): ExoPlayer {
|
||||
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
|
||||
// of drop events (rapid SOAP failures during a single tear-down) surfaces
|
||||
// as one snackbar rather than queueing N.
|
||||
private val dropEventsInternal = MutableSharedFlow<String>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
|
||||
|
||||
fun build(): Player {
|
||||
val exo = buildExoPlayer()
|
||||
return MinstrelForwardingPlayer(
|
||||
delegate = exo,
|
||||
holder = activeUpnpHolder,
|
||||
remoteState = remoteState,
|
||||
onDrop = { name -> emitDrop(name) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildExoPlayer(): ExoPlayer {
|
||||
val httpDataSource = OkHttpDataSource.Factory(okHttpClient)
|
||||
// Gate network reads on ServerHealth so a cache miss while offline
|
||||
// fails fast with an OfflineException instead of hitting an OkHttp
|
||||
// timeout. CacheDataSource only consults the upstream factory on a
|
||||
// cache miss, so playback of cached audio is unaffected.
|
||||
val gatedUpstream = OfflineGatedDataSourceFactory(httpDataSource, serverHealth)
|
||||
val cacheDataSource = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(httpDataSource)
|
||||
.setUpstreamDataSourceFactory(gatedUpstream)
|
||||
.setCacheWriteDataSinkFactory(
|
||||
CacheDataSink.Factory()
|
||||
.setCache(simpleCache)
|
||||
@@ -71,4 +115,26 @@ class PlayerFactory @Inject constructor(
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* BitmapLoader for the MediaSession's notification / lock-screen art.
|
||||
* Backed by the shared [okHttpClient] so it inherits the same
|
||||
* BaseUrlInterceptor placeholder rewrite + auth cookie that Coil uses
|
||||
* for in-app covers — without it the default DefaultHttpDataSource
|
||||
* loader can't resolve `http://placeholder.invalid/...` and would 401
|
||||
* on the cover endpoint. Wrapped in CacheBitmapLoader so a cover the
|
||||
* notification already fetched isn't re-loaded on every metadata
|
||||
* refresh. Lets the album-cover artworkUri set in
|
||||
* [PlayerController.toMediaItem] actually render on the media card.
|
||||
*/
|
||||
fun buildBitmapLoader(): BitmapLoader =
|
||||
CacheBitmapLoader(
|
||||
DataSourceBitmapLoader.Builder(context)
|
||||
.setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
|
||||
.build(),
|
||||
)
|
||||
|
||||
private fun emitDrop(routeName: String) {
|
||||
dropEventsInternal.tryEmit(routeName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,6 @@ data class PlayerUiState(
|
||||
val currentSource: String? = null,
|
||||
val shuffleEnabled: Boolean = false,
|
||||
val repeatMode: RepeatMode = RepeatMode.OFF,
|
||||
/** True while the UPnP initial-batch load is in progress (target set, active not yet wired). */
|
||||
val isUpnpLoading: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Synthesized state for the UPnP route -- what the ForwardingPlayer
|
||||
* exposes via Player.getCurrentPosition / isPlaying / etc. when the
|
||||
* remote leg is active. Not a Player; a container.
|
||||
*
|
||||
* Updates flow in from:
|
||||
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
||||
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
||||
* - Error paths -> applyError() (drop fallback) or recordPollFailure()
|
||||
*
|
||||
* The poll-failure counter implements the rolling-3 drop heuristic: 3
|
||||
* consecutive poll failures = remote considered dropped (returns true
|
||||
* from recordPollFailure for the caller to surface). Success resets it.
|
||||
*/
|
||||
@Singleton
|
||||
class RemotePlayerState @Inject constructor() {
|
||||
|
||||
@Volatile var positionMs: Long = 0L; private set
|
||||
@Volatile var durationMs: Long = 0L; private set
|
||||
@Volatile var isPlaying: Boolean = false; private set
|
||||
@Volatile var currentTrackUri: String = ""; private set
|
||||
@Volatile var lastError: Throwable? = null; private set
|
||||
@Volatile var trackNumber: Int = 0; private set
|
||||
|
||||
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
|
||||
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
||||
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
||||
// player's index, but Sonos's reported Track hasn't refreshed via a
|
||||
// SOAP poll yet. PlayerController.tickPositionPoll skips track
|
||||
// adjustments while pending is non-zero. Pending clears when:
|
||||
// (a) [event-driven, primary] a poll lands and Sonos's reported Track
|
||||
// matches the wrapped player's currentMediaItemIndex; or
|
||||
// (b) [safety fallback] the deadline expires (covers SOAP-fail cases
|
||||
// where Sonos never acks).
|
||||
@Volatile var pendingTransportDeadlineMs: Long = 0L; private set
|
||||
|
||||
fun beginPendingTransport(deadlineMs: Long) {
|
||||
pendingTransportDeadlineMs = deadlineMs
|
||||
}
|
||||
|
||||
fun clearPendingTransport() {
|
||||
pendingTransportDeadlineMs = 0L
|
||||
}
|
||||
|
||||
@Volatile private var consecutivePollFailures: Int = 0
|
||||
|
||||
fun applyPositionInfo(positionMs: Long, durationMs: Long, trackUri: String, trackNumber: Int) {
|
||||
this.positionMs = positionMs
|
||||
this.durationMs = durationMs
|
||||
this.currentTrackUri = trackUri
|
||||
this.trackNumber = trackNumber
|
||||
}
|
||||
|
||||
fun applyTransportPlaying() { isPlaying = true }
|
||||
fun applyTransportPaused() { isPlaying = false }
|
||||
fun applyTransportStopped() {
|
||||
isPlaying = false
|
||||
positionMs = 0L
|
||||
}
|
||||
|
||||
fun applyError(t: Throwable) {
|
||||
isPlaying = false
|
||||
lastError = t
|
||||
}
|
||||
|
||||
/** Returns true when the rolling threshold trips this call. */
|
||||
fun recordPollFailure(): Boolean {
|
||||
consecutivePollFailures += 1
|
||||
return consecutivePollFailures >= DROP_THRESHOLD
|
||||
}
|
||||
|
||||
fun recordPollSuccess() { consecutivePollFailures = 0 }
|
||||
|
||||
fun reset() {
|
||||
positionMs = 0L
|
||||
durationMs = 0L
|
||||
isPlaying = false
|
||||
currentTrackUri = ""
|
||||
lastError = null
|
||||
consecutivePollFailures = 0
|
||||
trackNumber = 0
|
||||
pendingTransportDeadlineMs = 0L
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// ~30 seconds of consecutive poll failures before declaring the route
|
||||
// dropped. Bumped from 3 because screen-off WiFi sleep / brief Doze
|
||||
// can stall socket I/O for several seconds without the renderer
|
||||
// actually being unreachable -- a 3-failure drop kicked us back to
|
||||
// local audio every time the phone went into a pocket.
|
||||
const val DROP_THRESHOLD = 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.CastApi
|
||||
import com.fabledsword.minstrel.api.endpoints.StreamTokenRequest
|
||||
import com.fabledsword.minstrel.api.endpoints.StreamTokenResponse
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Mints stream tokens for a given track id. Pulled into its own Hilt
|
||||
* singleton so [MinstrelForwardingPlayer] (service-side) and
|
||||
* [com.fabledsword.minstrel.player.output.OutputPickerController]
|
||||
* (controller-side) don't each construct their own [CastApi] from
|
||||
* Retrofit. The shared Retrofit instance is unchanged.
|
||||
*/
|
||||
@Singleton
|
||||
class StreamTokenProvider @Inject constructor(retrofit: Retrofit) {
|
||||
private val api: CastApi = retrofit.create()
|
||||
|
||||
suspend fun mint(trackId: String): StreamTokenResponse =
|
||||
api.streamToken(StreamTokenRequest(trackId = trackId))
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Shared singleton handle to the currently-active UPnP route's transport
|
||||
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
|
||||
* from [OutputPickerController] -- the picker writes; the forwarding
|
||||
* player reads. Null = no UPnP active (local ExoPlayer path).
|
||||
*/
|
||||
data class ActiveUpnp(
|
||||
val routeId: String,
|
||||
val routeName: String,
|
||||
val avTransport: AVTransportClient,
|
||||
val rendering: RenderingControlClient?,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class ActiveUpnpHolder @Inject constructor() {
|
||||
|
||||
private val internal = MutableStateFlow<ActiveUpnp?>(null)
|
||||
val active: StateFlow<ActiveUpnp?> = internal.asStateFlow()
|
||||
|
||||
/**
|
||||
* Pending route id during selectUpnp's queue-load window. Set when
|
||||
* loadQueueOnSonos starts; cleared on completion (success or failure).
|
||||
* ForwardingPlayer overrides treat (target != null && active == null)
|
||||
* as "UPnP intended but SOAP not yet wired" -- drop transport commands
|
||||
* silently rather than send them to a half-loaded queue.
|
||||
*/
|
||||
private val targetInternal = MutableStateFlow<String?>(null)
|
||||
val target: StateFlow<String?> = targetInternal.asStateFlow()
|
||||
|
||||
fun set(active: ActiveUpnp?) { internal.value = active }
|
||||
|
||||
fun setTarget(routeId: String?) { targetInternal.value = routeId }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Bluetooth
|
||||
import com.composables.icons.lucide.Cast
|
||||
import com.composables.icons.lucide.ChevronDown
|
||||
import com.composables.icons.lucide.Headphones
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Smartphone
|
||||
import com.composables.icons.lucide.Speaker
|
||||
|
||||
/**
|
||||
* Spotify-style chip showing the current output route. Sits between
|
||||
* BottomActionsRow and ScrubberRow in NowPlayingScreen. Tap to open
|
||||
* the picker sheet.
|
||||
*
|
||||
* Visibility rule per the spec: hidden when the route list has
|
||||
* exactly one entry AND that entry is BuiltIn — no reason to surface
|
||||
* a picker for "the only thing available." Visibility logic owned
|
||||
* by the caller (NowPlayingScreen).
|
||||
*/
|
||||
@Composable
|
||||
fun DeviceChip(
|
||||
route: OutputRoute,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = iconFor(route.kind),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(CHIP_ICON_DP.dp),
|
||||
)
|
||||
Text(
|
||||
text = route.name,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Lucide.ChevronDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(CHIP_CHEVRON_DP.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun iconFor(kind: OutputRoute.Kind): ImageVector = when (kind) {
|
||||
OutputRoute.Kind.BuiltIn -> Lucide.Smartphone
|
||||
OutputRoute.Kind.Wired -> Lucide.Headphones
|
||||
OutputRoute.Kind.Bluetooth -> Lucide.Bluetooth
|
||||
OutputRoute.Kind.Cast -> Lucide.Cast
|
||||
OutputRoute.Kind.Other -> Lucide.Speaker
|
||||
}
|
||||
|
||||
private const val CHIP_ICON_DP = 16
|
||||
private const val CHIP_CHEVRON_DP = 14
|
||||
+644
@@ -0,0 +1,644 @@
|
||||
@file:Suppress("TooManyFunctions") // 5 MediaRouter.Callback overrides inflate the count
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import android.content.Context
|
||||
import androidx.mediarouter.media.MediaControlIntent
|
||||
import androidx.mediarouter.media.MediaRouteSelector
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.player.PlayerFactory
|
||||
import com.fabledsword.minstrel.player.RemotePlayerState
|
||||
import com.fabledsword.minstrel.player.StreamTokenProvider
|
||||
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Snapshot of the audio output route state. [current] is the live
|
||||
* route audio is being delivered to. [available] is every route the
|
||||
* picker knows about -- MediaRouter system routes merged with
|
||||
* UPnP/DLNA renderers discovered on the LAN -- with the BuiltIn
|
||||
* "Phone speaker" pinned first, everything else alphabetical.
|
||||
*/
|
||||
data class RouteSnapshot(
|
||||
val current: OutputRoute,
|
||||
val available: List<OutputRoute>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hilt-singleton facade over [MediaRouter] + [UpnpDiscoveryController].
|
||||
* Owns the callback lifecycle — default passive behavior at process
|
||||
* start (route list updates without forcing Bluetooth scans), upgrades
|
||||
* to active discovery while the picker sheet is open so newly-paired
|
||||
* devices appear promptly. The [routesState] StateFlow projects the
|
||||
* current route + sorted available list as a [RouteSnapshot]; the
|
||||
* picker ViewModel collects it.
|
||||
*
|
||||
* Selection branches on [OutputRoute.Protocol]:
|
||||
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
|
||||
* wired, Bluetooth)
|
||||
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
|
||||
* [StreamTokenProvider.mint], drive the discovered renderer with
|
||||
* AVTransport.SetAVTransportURI + Play, pause local playback so
|
||||
* audio yields to the network speaker
|
||||
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
|
||||
* reserved for follow-up slices; ignored for now.
|
||||
*
|
||||
* Mirrors the OutputPickerController role described in
|
||||
* docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md
|
||||
* and the UPnP extensions in
|
||||
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
*/
|
||||
@Singleton
|
||||
class OutputPickerController @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val upnpDiscovery: UpnpDiscoveryController,
|
||||
private val playerController: PlayerController,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val streamTokens: StreamTokenProvider,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val okHttp: OkHttpClient,
|
||||
) {
|
||||
private val mediaRouter = MediaRouter.getInstance(context)
|
||||
|
||||
private val selector = MediaRouteSelector.Builder()
|
||||
.addControlCategory(MediaControlIntent.CATEGORY_LIVE_AUDIO)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Internal projection of the live MediaRouter snapshot. Refreshed
|
||||
* on every Callback event; combined downstream with UPnP routes
|
||||
* into the public [routesState].
|
||||
*/
|
||||
private val systemRoutesInternal = MutableStateFlow(snapshotFromRouter())
|
||||
|
||||
private val selectedUpnpRouteIdInternal = MutableStateFlow<String?>(null)
|
||||
private val selectUpnpMutex = Mutex()
|
||||
|
||||
val routesState: StateFlow<RouteSnapshot> = combine(
|
||||
systemRoutesInternal,
|
||||
upnpDiscovery.routes,
|
||||
upnpDiscovery.sonosTopology,
|
||||
selectedUpnpRouteIdInternal,
|
||||
) { sys, upnp, _, upnpSelected ->
|
||||
val suppressed = upnpDiscovery.nonCoordinatorMemberUdns()
|
||||
val visibleUpnp = upnp
|
||||
.filter { it.id.bareUdn() !in suppressed } // suppressed set is bare UDNs
|
||||
.map { OutputRoute.fromUpnpRoute(it) }
|
||||
val merged = sys.available + visibleUpnp
|
||||
val current = if (upnpSelected != null) {
|
||||
merged.firstOrNull { it.id == upnpSelected } ?: sys.current
|
||||
} else {
|
||||
sys.current
|
||||
}
|
||||
RouteSnapshot(current = current, available = sortRoutes(merged))
|
||||
}.stateIn(scope, SharingStarted.Eagerly, systemRoutesInternal.value)
|
||||
|
||||
private val callback = object : MediaRouter.Callback() {
|
||||
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) =
|
||||
refresh()
|
||||
|
||||
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) =
|
||||
refresh()
|
||||
|
||||
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) =
|
||||
refresh()
|
||||
|
||||
override fun onRouteSelected(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
reason: Int,
|
||||
) = refresh()
|
||||
|
||||
override fun onRouteUnselected(
|
||||
router: MediaRouter,
|
||||
route: MediaRouter.RouteInfo,
|
||||
reason: Int,
|
||||
) = refresh()
|
||||
}
|
||||
|
||||
// Last-synced queue identity. Used by observeQueueChangesForSonosResync
|
||||
// to detect when the user has mutated the queue (full replacement,
|
||||
// playNext insert, or radio-append) while UPnP is active and apply the
|
||||
// minimum-incremental set of Sonos SOAP operations to bring its native
|
||||
// queue back in sync. Stored as the full id list (not a join-key) so we
|
||||
// can run the longest-common-prefix / common-suffix diff.
|
||||
private var lastSyncedQueueIds: List<String>? = null
|
||||
|
||||
init {
|
||||
// Two-arg addCallback registers with no discovery flag —
|
||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||
// route-list updates flow through onRouteAdded/Removed/Changed
|
||||
// without forcing Bluetooth scans. (There is no
|
||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||
mediaRouter.addCallback(selector, callback)
|
||||
// When MinstrelForwardingPlayer reports the active UPnP route has
|
||||
// dropped (3+ consecutive poll failures or a transport SOAP exception),
|
||||
// clear the UPnP selection state and fall back to local ExoPlayer at
|
||||
// the last-known remote position. This mirrors selectSystem's disconnect
|
||||
// path but skips the Stop SOAP since the device is already unreachable.
|
||||
scope.launch {
|
||||
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
||||
}
|
||||
scope.launch { observeQueueChangesForSonosResync() }
|
||||
}
|
||||
|
||||
/**
|
||||
* When the user plays a different playlist while Sonos is active,
|
||||
* PlayerController.setQueue replaces the local queue but Sonos's
|
||||
* native queue still holds the OLD tracks. MinstrelForwardingPlayer's
|
||||
* setMediaItems override clears holder.active + sets target so the
|
||||
* imminent play() call drops (drops via isLoadingUpnp() = true). Then
|
||||
* this collector observes the uiState.queue change and re-runs
|
||||
* loadQueueOnSonos to push the new tracks to Sonos.
|
||||
*
|
||||
* Discrimination: selectUpnp's initial-load path doesn't change
|
||||
* uiState.queue (the queue was already populated before route
|
||||
* selection), so this collector doesn't fire during that window. Only
|
||||
* a fresh setQueue from PlayerController bumps the joined-ids key.
|
||||
*/
|
||||
private suspend fun observeQueueChangesForSonosResync() {
|
||||
playerController.uiState.collect { state ->
|
||||
val newIds = state.queue.map { it.id }
|
||||
val oldIds = lastSyncedQueueIds
|
||||
if (newIds == oldIds) return@collect
|
||||
lastSyncedQueueIds = newIds
|
||||
// Route can be in target (setMediaItems-induced clearing already
|
||||
// ran in ForwardingPlayer) OR in active (queue changed via
|
||||
// addMediaItem / removeMediaItems etc. which don't hit the
|
||||
// markPending hook).
|
||||
val routeId = activeUpnpHolder.target.value
|
||||
?: activeUpnpHolder.active.value?.routeId
|
||||
?: return@collect
|
||||
if (state.queue.isEmpty()) {
|
||||
Timber.w("Sonos resync skipped: empty queue (clearing target)")
|
||||
activeUpnpHolder.setTarget(null)
|
||||
return@collect
|
||||
}
|
||||
scope.launch {
|
||||
resyncSonosQueue(routeId, oldIds.orEmpty(), state.queue, state.queueIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring Sonos's native queue back in sync with the local queue after a
|
||||
* mutation. Tries an incremental SOAP diff first (RemoveTrackRangeFromQueue
|
||||
* + AddURIToQueue at the insertion point) so playback continues without
|
||||
* interruption -- that's what playNext / radio-append want. Falls back to
|
||||
* the full removeAllTracks + reload path when the diff implies the current
|
||||
* Sonos track was deleted (e.g. user switched playlists), which is what
|
||||
* the user-reported "Sonos queue does not update" bug needed.
|
||||
*/
|
||||
private suspend fun resyncSonosQueue(
|
||||
routeId: String,
|
||||
oldIds: List<String>,
|
||||
newQueue: List<TrackRef>,
|
||||
newCurrentIndex: Int,
|
||||
) = selectUpnpMutex.withLock {
|
||||
val upnpRoute = upnpDiscovery.routes.value.firstOrNull { it.id == routeId }
|
||||
val transport = upnpDiscovery.transportFor(routeId)
|
||||
if (upnpRoute == null || transport == null) {
|
||||
Timber.w(
|
||||
"Sonos resync: route or transport gone for %s, dropping to local",
|
||||
routeId,
|
||||
)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
return@withLock
|
||||
}
|
||||
val handledIncrementally = runCatching {
|
||||
tryIncrementalResync(transport, oldIds, newQueue)
|
||||
}.getOrElse { e ->
|
||||
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
|
||||
false
|
||||
}
|
||||
if (handledIncrementally) {
|
||||
// Active was never cleared on the incremental path; clear any
|
||||
// target that markPendingResyncIfRemote set (it didn't, for
|
||||
// incremental cases that don't go through setMediaItems, but
|
||||
// belt-and-suspenders).
|
||||
activeUpnpHolder.setTarget(null)
|
||||
return@withLock
|
||||
}
|
||||
// Full rebuild: ensure active is cleared so transport calls drop
|
||||
// (markPendingResyncIfRemote may already have done this on the
|
||||
// setMediaItems path).
|
||||
if (activeUpnpHolder.active.value != null) {
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(routeId)
|
||||
}
|
||||
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
|
||||
val rendering = renderingClientFor(routeId)
|
||||
Timber.w("Sonos resync: full reload of %d tracks on %s", newQueue.size, outputRoute.name)
|
||||
runCatching {
|
||||
loadQueueOnSonos(transport, outputRoute, newQueue, newCurrentIndex)
|
||||
activeUpnpHolder.set(
|
||||
ActiveUpnp(
|
||||
routeId = routeId,
|
||||
routeName = outputRoute.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
),
|
||||
)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
}.onFailure { e ->
|
||||
Timber.w(e, "Sonos resync (full) failed -- dropping to local")
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff-based incremental Sonos queue sync. Returns true when the new
|
||||
* queue can be produced from the old one with a remove-then-insert at
|
||||
* the same middle slice -- the common-prefix and common-suffix portions
|
||||
* stay untouched, and the current Sonos track must lie in the preserved
|
||||
* prefix (otherwise the diff would orphan playback). Returns false to
|
||||
* signal the caller to fall back to a full reload.
|
||||
*/
|
||||
private suspend fun tryIncrementalResync(
|
||||
transport: AVTransportClient,
|
||||
oldIds: List<String>,
|
||||
newQueue: List<TrackRef>,
|
||||
): Boolean {
|
||||
val newIds = newQueue.map { it.id }
|
||||
if (oldIds == newIds) return true
|
||||
val prefixLen = commonPrefixLength(oldIds, newIds)
|
||||
val suffixLen = commonSuffixLength(
|
||||
oldIds.subList(prefixLen, oldIds.size),
|
||||
newIds.subList(prefixLen, newIds.size),
|
||||
)
|
||||
val removedCount = oldIds.size - prefixLen - suffixLen
|
||||
val addedCount = newIds.size - prefixLen - suffixLen
|
||||
// Sonos's current track number is 1-based; compare against the
|
||||
// preserved-prefix range as 0-based. If the current track is in
|
||||
// the removed slice, incremental can't preserve playback -- caller
|
||||
// falls back to full rebuild.
|
||||
val currentSonosIdx0 = remoteState.trackNumber - 1
|
||||
val canApply = currentSonosIdx0 in 0 until prefixLen
|
||||
if (canApply) {
|
||||
applyQueueDiff(transport, newQueue, prefixLen, removedCount, addedCount)
|
||||
} else {
|
||||
Timber.w(
|
||||
"Sonos incremental: current track %d not in preserved prefix [0,%d); full rebuild",
|
||||
currentSonosIdx0,
|
||||
prefixLen,
|
||||
)
|
||||
}
|
||||
return canApply
|
||||
}
|
||||
|
||||
private suspend fun applyQueueDiff(
|
||||
transport: AVTransportClient,
|
||||
newQueue: List<TrackRef>,
|
||||
prefixLen: Int,
|
||||
removedCount: Int,
|
||||
addedCount: Int,
|
||||
) {
|
||||
if (removedCount > 0) {
|
||||
Timber.w(
|
||||
"Sonos incremental: RemoveTrackRangeFromQueue start=%d count=%d",
|
||||
prefixLen + 1,
|
||||
removedCount,
|
||||
)
|
||||
transport.removeTrackRangeFromQueue(
|
||||
startingIndex = prefixLen + 1,
|
||||
numberOfTracks = removedCount,
|
||||
)
|
||||
}
|
||||
if (addedCount == 0) return
|
||||
Timber.w(
|
||||
"Sonos incremental: AddURIToQueue x%d starting at position %d",
|
||||
addedCount,
|
||||
prefixLen + 1,
|
||||
)
|
||||
for (i in 0 until addedCount) {
|
||||
val ref = newQueue[prefixLen + i]
|
||||
val token = streamTokens.mint(ref.id)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
title = token.title,
|
||||
enqueuedURIPosition = prefixLen + i + 1,
|
||||
)
|
||||
if (i > 0) delay(EXTEND_THROTTLE_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun commonPrefixLength(a: List<String>, b: List<String>): Int {
|
||||
val limit = minOf(a.size, b.size)
|
||||
for (i in 0 until limit) {
|
||||
if (a[i] != b[i]) return i
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
private fun commonSuffixLength(a: List<String>, b: List<String>): Int {
|
||||
val limit = minOf(a.size, b.size)
|
||||
for (i in 0 until limit) {
|
||||
if (a[a.size - 1 - i] != b[b.size - 1 - i]) return i
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the active UPnP route drops unexpectedly (poll-failure
|
||||
* threshold or SOAP exception). Captures the last remote position +
|
||||
* play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||
* the same point. Skips the Stop SOAP (device already unreachable).
|
||||
* The snackbar is handled independently by the NowPlaying surface
|
||||
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
||||
*/
|
||||
private fun handleRemoteDrop() {
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
val wasPlayingRemote = remoteState.isPlaying
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
playerController.seekTo(capturedPositionMs)
|
||||
if (wasPlayingRemote) playerController.play()
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade callback registration to active discovery — call when
|
||||
* the picker sheet opens so newly-paired Bluetooth devices
|
||||
* surface within a few seconds, and fire an SSDP M-SEARCH burst
|
||||
* for UPnP renderers. Idempotent: re-registering with a
|
||||
* new flag set replaces the prior registration in MediaRouter.
|
||||
*/
|
||||
fun upgradeDiscovery() {
|
||||
mediaRouter.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
|
||||
upnpDiscovery.upgradeDiscovery()
|
||||
}
|
||||
|
||||
/**
|
||||
* Downgrade callback registration back to default passive behavior
|
||||
* — call when the picker sheet closes so we don't keep Bluetooth
|
||||
* scanning on for battery cost. Two-arg overload = no flag =
|
||||
* passive. Same idempotent re-registration semantics. The UPnP
|
||||
* side has no symmetric downgrade (passive SSDP NOTIFY listen is
|
||||
* always on); the call is preserved for API parity.
|
||||
*/
|
||||
fun downgradeDiscovery() {
|
||||
mediaRouter.addCallback(selector, callback)
|
||||
upnpDiscovery.downgradeDiscovery()
|
||||
}
|
||||
|
||||
/**
|
||||
* Select [route]. SYSTEM routes hand off to [MediaRouter]; UPNP
|
||||
* routes mint a signed stream token + drive AVTransport on the
|
||||
* discovered renderer + pause local playback. Other protocols
|
||||
* (CAST / SONOS) are reserved for follow-up slices and are
|
||||
* silently ignored — the picker shouldn't show them yet.
|
||||
*/
|
||||
fun select(route: OutputRoute) {
|
||||
when (route.protocol) {
|
||||
OutputRoute.Protocol.SYSTEM -> selectSystem(route)
|
||||
OutputRoute.Protocol.UPNP -> scope.launch { selectUpnp(route) }
|
||||
OutputRoute.Protocol.CAST, OutputRoute.Protocol.SONOS -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectSystem(route: OutputRoute) {
|
||||
val wasUpnp = selectedUpnpRouteIdInternal.value
|
||||
if (wasUpnp != null) {
|
||||
val active = activeUpnpHolder.active.value
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
val wasPlayingRemote = remoteState.isPlaying
|
||||
scope.launch {
|
||||
runCatching { active?.avTransport?.stop() }
|
||||
.onFailure { Timber.w(it, "UPnP Stop failed during disconnect") }
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
playerController.seekTo(capturedPositionMs)
|
||||
if (wasPlayingRemote) playerController.play()
|
||||
}
|
||||
}
|
||||
val target = mediaRouter.routes.firstOrNull { it.id == route.id } ?: return
|
||||
mediaRouter.selectRoute(target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the UPnP renderer using Sonos native queue mode:
|
||||
* clear the device's queue, load every track from our local queue
|
||||
* via AddURIToQueue, point the transport at the queue URI, seek to
|
||||
* the current index, and play. Wrapped in `runCatching` — SOAP
|
||||
* failure abandons the selection cleanly.
|
||||
*
|
||||
* Order of operations is deliberate:
|
||||
* 1. Pause local so the user doesn't keep hearing local audio.
|
||||
* 2. Set target early so ForwardingPlayer drops transport taps
|
||||
* while the 17-second queue load is in progress.
|
||||
* 3. Wire active LAST (after loadQueueOnSonos) so SOAP commands
|
||||
* are never routed to a half-loaded Sonos queue.
|
||||
*/
|
||||
private suspend fun selectUpnp(route: OutputRoute) = selectUpnpMutex.withLock {
|
||||
val uiState = playerController.uiState.value
|
||||
val currentTrack = uiState.currentTrack
|
||||
if (currentTrack == null) {
|
||||
Timber.w("UPnP select skipped: no currentTrack (start playback first)")
|
||||
return@withLock
|
||||
}
|
||||
// Honor Sonos topology: pick the coordinator's route when the user
|
||||
// tapped a group row. Suppression in routesState already keeps the
|
||||
// visible row at the coordinator's id, so this is identity in the
|
||||
// common case -- defensive for follow-up flows.
|
||||
val effectiveRoute = upnpDiscovery.coordinatorRouteFor(route.id)
|
||||
?.let { OutputRoute.fromUpnpRoute(it) } ?: route
|
||||
val transport = upnpDiscovery.transportFor(effectiveRoute.id)
|
||||
if (transport == null) {
|
||||
Timber.w(
|
||||
"UPnP select skipped: no transport for route id=${effectiveRoute.id} " +
|
||||
"(route disappeared or id mismatch with discovery list)",
|
||||
)
|
||||
return@withLock
|
||||
}
|
||||
val rendering = renderingClientFor(effectiveRoute.id)
|
||||
// Pause local before flipping UI state -- user shouldn't keep hearing
|
||||
// local audio while we queue up Sonos.
|
||||
playerController.pause()
|
||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||
// silently while target is set but active is null -- the user's premature
|
||||
// taps don't hit Sonos's stale state from a prior session.
|
||||
activeUpnpHolder.setTarget(effectiveRoute.id)
|
||||
runCatching {
|
||||
loadQueueOnSonos(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
|
||||
// Wire active LAST -- SOAP path is now safe to use.
|
||||
activeUpnpHolder.set(
|
||||
ActiveUpnp(
|
||||
routeId = effectiveRoute.id,
|
||||
routeName = effectiveRoute.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
),
|
||||
)
|
||||
}.onFailure { e ->
|
||||
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadQueueOnSonos(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
queue: List<TrackRef>,
|
||||
currentIndex: Int,
|
||||
) {
|
||||
Timber.w("UPnP select: clear queue on %s", route.name)
|
||||
transport.removeAllTracksFromQueue()
|
||||
val initialEnd = (currentIndex + 1).coerceAtMost(queue.size)
|
||||
val initialBatch = queue.subList(0, initialEnd)
|
||||
Timber.w(
|
||||
"UPnP select: add %d initial tracks (currentIndex=%d, totalQueue=%d)",
|
||||
initialBatch.size, currentIndex, queue.size,
|
||||
)
|
||||
initialBatch.forEachIndexed { idx, ref ->
|
||||
val token = streamTokens.mint(ref.id)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
title = token.title,
|
||||
enqueuedURIPosition = idx + 1,
|
||||
)
|
||||
}
|
||||
val coordinatorUdn = route.id.bareUdn()
|
||||
val queueUri = "x-rincon-queue:$coordinatorUdn#0"
|
||||
Timber.w("UPnP select: SetAVTransportURI %s", queueUri)
|
||||
transport.setAVTransportURI(queueUri, "")
|
||||
Timber.w("UPnP select: Seek to track %d", currentIndex + 1)
|
||||
transport.seekToTrack(currentIndex + 1)
|
||||
Timber.w("UPnP select: Play")
|
||||
transport.play()
|
||||
Timber.w("UPnP select: initial done; backgrounding remainder")
|
||||
val remaining = queue.drop(initialEnd)
|
||||
if (remaining.isNotEmpty()) {
|
||||
scope.launch { extendQueueOnSonos(transport, route, remaining, initialEnd) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Background-append tracks after activation. Runs concurrently with
|
||||
* Sonos playback. Cancels if the user disconnects from this route
|
||||
* (active.routeId changes or becomes null). Tolerates individual
|
||||
* AddURIToQueue failures — log and continue so some tracks loaded
|
||||
* is better than zero tracks loaded.
|
||||
*/
|
||||
private suspend fun extendQueueOnSonos(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
tracks: List<TrackRef>,
|
||||
startPosition: Int,
|
||||
) {
|
||||
Timber.w(
|
||||
"UPnP extend: appending %d tracks starting at position %d",
|
||||
tracks.size, startPosition + 1,
|
||||
)
|
||||
var consecutiveFailures = 0
|
||||
var succeeded = 0
|
||||
var aborted = false
|
||||
for ((i, ref) in tracks.withIndex()) {
|
||||
if (aborted) break
|
||||
if (activeUpnpHolder.active.value?.routeId != route.id) {
|
||||
Timber.w("UPnP extend: cancelled at offset %d (route changed)", i)
|
||||
aborted = true
|
||||
} else {
|
||||
val outcome = runCatching {
|
||||
val token = streamTokens.mint(ref.id)
|
||||
transport.addURIToQueue(
|
||||
uri = token.url,
|
||||
mime = token.mime,
|
||||
title = token.title,
|
||||
enqueuedURIPosition = startPosition + i + 1,
|
||||
)
|
||||
}
|
||||
if (outcome.isSuccess) {
|
||||
consecutiveFailures = 0
|
||||
succeeded += 1
|
||||
// Throttle the burst so we don't tickle Sonos's burst-add
|
||||
// rejection -- logcat 2026-06-04 showed 33 consecutive
|
||||
// failures clustered at ~10ms intervals once offset 39 was
|
||||
// reached, which looks like a rate-limit kicking in. The
|
||||
// delay is small enough that extending 100 tracks adds
|
||||
// only ~5s to background work that's already async.
|
||||
delay(EXTEND_THROTTLE_MS)
|
||||
} else {
|
||||
consecutiveFailures += 1
|
||||
val e = outcome.exceptionOrNull()
|
||||
val detail = (e as? SoapFaultException)?.let {
|
||||
"code=${it.code} desc=${it.description}"
|
||||
} ?: e?.message
|
||||
Timber.w(e, "UPnP extend: append failed at offset %d -- %s", i, detail)
|
||||
if (consecutiveFailures >= EXTEND_ABORT_AFTER_FAILURES) {
|
||||
Timber.w(
|
||||
"UPnP extend: aborting after %d consecutive failures",
|
||||
consecutiveFailures,
|
||||
)
|
||||
aborted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.w("UPnP extend: done (%d / %d appended)", succeeded, tracks.size)
|
||||
}
|
||||
|
||||
private fun renderingClientFor(routeId: String): RenderingControlClient? {
|
||||
val rcUrl = upnpDiscovery.routes.value
|
||||
.firstOrNull { it.id == routeId }
|
||||
?.renderingControlUrl ?: return null
|
||||
return RenderingControlClient(SoapClient(okHttp), rcUrl)
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
systemRoutesInternal.value = snapshotFromRouter()
|
||||
}
|
||||
|
||||
private fun snapshotFromRouter(): RouteSnapshot {
|
||||
val all = mediaRouter.routes
|
||||
.filter { it.matchesSelector(selector) }
|
||||
.map { OutputRoute.fromRouteInfo(it) }
|
||||
val current = OutputRoute.fromRouteInfo(mediaRouter.selectedRoute)
|
||||
return RouteSnapshot(current = current, available = sortRoutes(all))
|
||||
}
|
||||
|
||||
/**
|
||||
* BuiltIn "Phone speaker" pinned first; everything else
|
||||
* alphabetical. Selection state is conveyed by the radio button
|
||||
* indicator in the picker row, not by sort order.
|
||||
*/
|
||||
private fun sortRoutes(all: List<OutputRoute>): List<OutputRoute> {
|
||||
val (builtIn, rest) = all.partition { it.kind == OutputRoute.Kind.BuiltIn }
|
||||
return builtIn + rest.sortedBy { it.name.lowercase() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||
const val EXTEND_THROTTLE_MS = 50L
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composables.icons.lucide.Circle
|
||||
import com.composables.icons.lucide.CircleCheck
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Settings
|
||||
import com.composables.icons.lucide.WifiOff
|
||||
|
||||
/**
|
||||
* Material 3 ModalBottomSheet listing the available output routes.
|
||||
* Tap a row to select + dismiss. Selected route shows the accent
|
||||
* CircleCheck; others show an empty Circle. Long route names
|
||||
* truncate cleanly via maxLines = 2 + Ellipsis.
|
||||
*
|
||||
* Two footer hints, each driven by a flag the host screen owns:
|
||||
* - [permissionDenied] — NowPlayingScreen owns the BLUETOOTH_CONNECT
|
||||
* request flow and sets this true on denial so the user sees the
|
||||
* "pair in Settings" affordance.
|
||||
* - [noUpnpDiscovered] — set true when the picker has been open for
|
||||
* a few seconds and no UPnP renderers have arrived; surfaces the
|
||||
* "router may be blocking multicast" hint. Defaults to false so
|
||||
* existing call sites that haven't wired the discovery-timing
|
||||
* logic continue to render without the hint.
|
||||
*
|
||||
* Keeping the hint flags external preserves this composable's
|
||||
* focus-on-rendering shape.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OutputPickerSheet(
|
||||
snapshot: RouteSnapshot,
|
||||
permissionDenied: Boolean,
|
||||
onRouteSelected: (OutputRoute) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
noUpnpDiscovered: Boolean = false,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Output",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = ROUTE_LIST_MAX_HEIGHT_DP.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(items = snapshot.available, key = { it.id }) { route ->
|
||||
RouteRow(
|
||||
route = route,
|
||||
isSelected = route.id == snapshot.current.id,
|
||||
onClick = { onRouteSelected(route) },
|
||||
)
|
||||
}
|
||||
}
|
||||
if (permissionDenied) {
|
||||
PermissionHintRow()
|
||||
}
|
||||
if (noUpnpDiscovered) {
|
||||
MulticastHintRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RouteRow(
|
||||
route: OutputRoute,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tint = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = iconFor(route.kind),
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(ROW_ICON_DP.dp),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = route.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = route.description ?: defaultSubtitle(route)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = if (isSelected) Lucide.CircleCheck else Lucide.Circle,
|
||||
contentDescription = if (isSelected) "Selected" else "Not selected",
|
||||
tint = tint,
|
||||
modifier = Modifier.size(ROW_ICON_DP.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionHintRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Settings,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(HINT_ICON_DP.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Pair a Bluetooth device in Settings to see it here.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MulticastHintRow() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.WifiOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(HINT_ICON_DP.dp),
|
||||
)
|
||||
Text(
|
||||
text = "Your router may be blocking multicast discovery.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) {
|
||||
OutputRoute.Kind.BuiltIn -> "Phone speaker"
|
||||
OutputRoute.Kind.Wired -> "Wired"
|
||||
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available"
|
||||
OutputRoute.Kind.Cast -> "Cast"
|
||||
OutputRoute.Kind.Other -> "Available"
|
||||
}
|
||||
|
||||
private const val ROW_ICON_DP = 24
|
||||
private const val HINT_ICON_DP = 20
|
||||
private const val ROUTE_LIST_MAX_HEIGHT_DP = 400
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* NowPlaying-scoped projection over [OutputPickerController]. The
|
||||
* controller is the long-lived Hilt singleton (its MediaRouter
|
||||
* callback must not churn with screen lifecycle); this ViewModel is
|
||||
* a thin lens over its [OutputPickerController.routesState] Flow
|
||||
* plus the sheet's visibility state.
|
||||
*
|
||||
* Sheet open/close are forwarded to the controller's discovery
|
||||
* toggle so active MediaRouter discovery only runs while the sheet
|
||||
* is actually visible.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class OutputPickerViewModel @Inject constructor(
|
||||
private val controller: OutputPickerController,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
) : ViewModel() {
|
||||
|
||||
val routes: StateFlow<RouteSnapshot> = controller.routesState
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS),
|
||||
initialValue = controller.routesState.value,
|
||||
)
|
||||
|
||||
val activeUpnp: StateFlow<ActiveUpnp?> = activeUpnpHolder.active
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null)
|
||||
|
||||
private val sheetVisibleInternal = MutableStateFlow(false)
|
||||
val sheetVisible: StateFlow<Boolean> = sheetVisibleInternal.asStateFlow()
|
||||
|
||||
fun onChipTapped() {
|
||||
sheetVisibleInternal.value = true
|
||||
controller.upgradeDiscovery()
|
||||
}
|
||||
|
||||
fun onSheetDismissed() {
|
||||
sheetVisibleInternal.value = false
|
||||
controller.downgradeDiscovery()
|
||||
}
|
||||
|
||||
fun onRouteSelected(route: OutputRoute) {
|
||||
controller.select(route)
|
||||
sheetVisibleInternal.value = false
|
||||
controller.downgradeDiscovery()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// SharingStarted timeout so quick screen-orientation changes
|
||||
// don't tear down + re-subscribe the controller's Flow.
|
||||
const val STOP_TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
|
||||
|
||||
/**
|
||||
* Narrow domain model for an audio output route. Independent of
|
||||
* [MediaRouter.RouteInfo] so the picker UI can preview without an
|
||||
* Android framework presence (RouteInfo can't be constructed in
|
||||
* JVM tests, mirroring the LikeMediaCallback constraint).
|
||||
*
|
||||
* The [protocol] field is a forward-compatibility hook for the
|
||||
* UPnP / DLNA / Sonos / Cast slice (scope captured in
|
||||
* docs/superpowers/specs/2026-06-03-android-output-picker-upnp-scope.md).
|
||||
* THIS slice always sets `protocol = SYSTEM` — the system-route
|
||||
* categories MediaRouter surfaces (built-in / wired / Bluetooth).
|
||||
*/
|
||||
data class OutputRoute(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val kind: Kind,
|
||||
val protocol: Protocol,
|
||||
val isConnected: Boolean,
|
||||
) {
|
||||
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
|
||||
|
||||
enum class Protocol {
|
||||
/** System-managed routes — built-in speaker, wired, Bluetooth. */
|
||||
SYSTEM,
|
||||
|
||||
/** Generic UPnP / DLNA renderers — reserved for the next slice. */
|
||||
UPNP,
|
||||
|
||||
/** Chromecast via the Cast SDK — reserved for a later slice. */
|
||||
CAST,
|
||||
|
||||
/** Sonos via the Sonos extension on top of UPnP — reserved. */
|
||||
SONOS,
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Lift a MediaRouter [route] into the domain model. Kind is
|
||||
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
|
||||
* device types fall through to [Kind.Other]. The
|
||||
* `connectionState` proxy is good enough for the chip's
|
||||
* "Connected"/"Available" subtitle.
|
||||
*/
|
||||
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
|
||||
val kind = when (route.deviceType) {
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_BUILTIN_SPEAKER -> Kind.BuiltIn
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADSET,
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_WIRED_HEADPHONES,
|
||||
-> Kind.Wired
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_BLUETOOTH_A2DP -> Kind.Bluetooth
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_TV -> Kind.Other
|
||||
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
|
||||
else -> Kind.Other
|
||||
}
|
||||
val connected =
|
||||
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
|
||||
return OutputRoute(
|
||||
id = route.id,
|
||||
name = route.name,
|
||||
description = route.description,
|
||||
kind = kind,
|
||||
protocol = Protocol.SYSTEM,
|
||||
isConnected = connected,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift a discovered UPnP renderer into the picker's domain
|
||||
* model. Used by the UPnP discovery controller to merge
|
||||
* network speakers into the same `OutputPickerController`
|
||||
* routes stream the system routes come through.
|
||||
*
|
||||
* `isConnected = false` because UPnP devices have no
|
||||
* MediaRouter connection-state concept — they're always
|
||||
* "available" on the LAN, and the picker's selected-route
|
||||
* rendering handles the "currently playing" indicator.
|
||||
*
|
||||
* Subtitle is `manufacturer modelName` joined by a single
|
||||
* space, falling back to "Network speaker" when both fields
|
||||
* are blank.
|
||||
*/
|
||||
fun fromUpnpRoute(route: UpnpRoute): OutputRoute {
|
||||
val description = listOfNotNull(
|
||||
route.manufacturer.takeIf { it.isNotBlank() },
|
||||
route.modelName.takeIf { it.isNotBlank() },
|
||||
).joinToString(" ").ifBlank { "Network speaker" }
|
||||
return OutputRoute(
|
||||
id = route.id,
|
||||
name = route.name,
|
||||
description = description,
|
||||
kind = Kind.Other,
|
||||
protocol = Protocol.UPNP,
|
||||
isConnected = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* High-level wrapper for the UPnP AVTransport service.
|
||||
* Covers SetAVTransportURI / Play / Pause / Stop / Seek /
|
||||
* GetPositionInfo / GetTransportInfo / queue management
|
||||
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
|
||||
*/
|
||||
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
|
||||
@Suppress("TooManyFunctions")
|
||||
class AVTransportClient(
|
||||
private val soap: SoapClient,
|
||||
private val controlUrl: HttpUrl,
|
||||
) {
|
||||
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "SetAVTransportURI",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"CurrentURI" to uri,
|
||||
"CurrentURIMetaData" to metadata,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience overload that builds DIDL-Lite metadata around [uri]
|
||||
* + [mime] + [title] and forwards to [setAVTransportURI]. Sonos
|
||||
* rejects empty DIDL with vendor error 1023; this constructs the
|
||||
* minimal-but-Sonos-acceptable shape:
|
||||
* <DIDL-Lite>
|
||||
* <item id="0" parentID="-1" restricted="1">
|
||||
* <dc:title>...</dc:title>
|
||||
* <upnp:class>object.item.audioItem.musicTrack</upnp:class>
|
||||
* <res protocolInfo="http-get:*:<mime>:*">...</res>
|
||||
* </item>
|
||||
* </DIDL-Lite>
|
||||
* Generic UPnP renderers tolerate this shape too — there's no
|
||||
* downside to always sending it. Title falls back to "Minstrel"
|
||||
* when the caller doesn't supply one.
|
||||
*/
|
||||
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
|
||||
val safeTitle = title.ifBlank { "Minstrel" }
|
||||
setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all tracks from the renderer's queue. UPnP action name is
|
||||
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
|
||||
* leftover queue from prior sessions before loading our local queue.
|
||||
*/
|
||||
suspend fun removeAllTracksFromQueue() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "RemoveAllTracksFromQueue",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a contiguous range of tracks from the renderer's native queue.
|
||||
* Sonos-specific extension to AVTransport; `UpdateID=0` skips the queue-
|
||||
* version check so this works without first calling GetQueue to learn
|
||||
* the current update id.
|
||||
*
|
||||
* [startingIndex] is 1-based per Sonos convention; [numberOfTracks] is
|
||||
* the count to remove. Used by OutputPickerController's incremental
|
||||
* queue resync path (radio-append: remove tail, then AddURIToQueue
|
||||
* the new items).
|
||||
*/
|
||||
suspend fun removeTrackRangeFromQueue(startingIndex: Int, numberOfTracks: Int) {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "RemoveTrackRangeFromQueue",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"UpdateID" to "0",
|
||||
"StartingIndex" to startingIndex.toString(),
|
||||
"NumberOfTracks" to numberOfTracks.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a track to the renderer's queue. Sonos returns the assigned
|
||||
* track number + new total queue length in the response, but we don't
|
||||
* read those (we know our intended position). DIDL-Lite metadata is
|
||||
* required; reuses the same shape as [setAVTransportURIWithMetadata].
|
||||
*
|
||||
* [enqueuedURIPosition] is 1-based; 0 means "append to end" per UPnP.
|
||||
* Our caller passes 1, 2, 3, ... to ensure stable order.
|
||||
*/
|
||||
suspend fun addURIToQueue(
|
||||
uri: String,
|
||||
mime: String,
|
||||
title: String,
|
||||
enqueuedURIPosition: Int = 0,
|
||||
) {
|
||||
val safeTitle = title.ifBlank { "Minstrel" }
|
||||
val didl = buildDidlLite(uri, mime, safeTitle)
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "AddURIToQueue",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"EnqueuedURI" to uri,
|
||||
"EnqueuedURIMetaData" to didl,
|
||||
"DesiredFirstTrackNumberEnqueued" to enqueuedURIPosition.toString(),
|
||||
"EnqueueAsNext" to "0",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Skip to the next track in the renderer's queue. */
|
||||
suspend fun next() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Next",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
/** Skip to the previous track in the renderer's queue. */
|
||||
suspend fun previous() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Previous",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to a specific track in the queue. UPnP Seek unit "TRACK_NR";
|
||||
* target is the 1-based track index. Separate from the existing
|
||||
* [seek] which uses unit REL_TIME for position-within-track.
|
||||
*/
|
||||
suspend fun seekToTrack(trackNumber: Int) {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Seek",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"Unit" to "TRACK_NR",
|
||||
"Target" to trackNumber.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun play() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Play",
|
||||
args = mapOf("InstanceID" to "0", "Speed" to "1"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun pause() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Pause",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun stop() {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Stop",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun seek(positionMs: Long) {
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "Seek",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"Unit" to "REL_TIME",
|
||||
"Target" to formatHhMmSs(positionMs),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getPositionInfo(): PositionInfo {
|
||||
val result = soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "GetPositionInfo",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
return PositionInfo(
|
||||
track = result["Track"]?.toIntOrNull() ?: 0,
|
||||
trackUri = result["TrackURI"].orEmpty(),
|
||||
relTimeMs = parseHhMmSs(result["RelTime"].orEmpty()),
|
||||
trackDurationMs = parseHhMmSs(result["TrackDuration"].orEmpty()),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getTransportInfo(): TransportInfo {
|
||||
val result = soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "GetTransportInfo",
|
||||
args = mapOf("InstanceID" to "0"),
|
||||
)
|
||||
val state = when (result["CurrentTransportState"]) {
|
||||
"PLAYING" -> TransportState.PLAYING
|
||||
"PAUSED_PLAYBACK" -> TransportState.PAUSED
|
||||
"STOPPED" -> TransportState.STOPPED
|
||||
"TRANSITIONING" -> TransportState.TRANSITIONING
|
||||
else -> TransportState.UNKNOWN
|
||||
}
|
||||
return TransportInfo(state)
|
||||
}
|
||||
|
||||
private fun buildDidlLite(uri: String, mime: String, title: String): String {
|
||||
// Sonos requires (a) the rinconnetworks namespace declared on
|
||||
// <DIDL-Lite> even if we don't use Rincon elements directly, and
|
||||
// (b) a <desc id="cdudn"> element identifying the URI as an
|
||||
// external (non-Sonos-library) source. Without those, Sonos
|
||||
// discards our metadata content and regenerates its own with
|
||||
// class=object.item and the URL query string as the title
|
||||
// (logcat 2026-06-04 confirmed). Match SoCo's pattern.
|
||||
val safeTitle = title.ifBlank { "Minstrel" }
|
||||
return buildString {
|
||||
append("<DIDL-Lite ")
|
||||
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
|
||||
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
|
||||
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" ")
|
||||
append("xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
|
||||
append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">")
|
||||
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
|
||||
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
|
||||
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
|
||||
append(":*\">").append(xmlEscape(uri)).append("</res>")
|
||||
append("<desc id=\"cdudn\" ")
|
||||
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
|
||||
append("RINCON_AssociatedZPUDN")
|
||||
append("</desc>")
|
||||
append("</item>")
|
||||
append("</DIDL-Lite>")
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatHhMmSs(positionMs: Long): String {
|
||||
val totalSec = (positionMs / MILLIS_PER_SECOND).coerceAtLeast(0)
|
||||
val h = totalSec / SECONDS_PER_HOUR
|
||||
val m = (totalSec % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE
|
||||
val s = totalSec % SECONDS_PER_MINUTE
|
||||
return "%d:%02d:%02d".format(h, m, s)
|
||||
}
|
||||
|
||||
private fun parseHhMmSs(raw: String): Long {
|
||||
val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() }
|
||||
return if (parts.size == EXPECTED_HMS_PARTS) {
|
||||
val (h, m, s) = parts
|
||||
((h * SECONDS_PER_HOUR) + (m * SECONDS_PER_MINUTE) + s) * MILLIS_PER_SECOND
|
||||
} else {
|
||||
0L
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
||||
const val MILLIS_PER_SECOND = 1000L
|
||||
const val SECONDS_PER_MINUTE = 60L
|
||||
const val SECONDS_PER_HOUR = 3600L
|
||||
const val EXPECTED_HMS_PARTS = 3
|
||||
}
|
||||
}
|
||||
|
||||
data class PositionInfo(
|
||||
val track: Int,
|
||||
val trackUri: String,
|
||||
val relTimeMs: Long,
|
||||
val trackDurationMs: Long,
|
||||
)
|
||||
|
||||
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
|
||||
|
||||
data class TransportInfo(val state: TransportState)
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
|
||||
/**
|
||||
* Pull-parsed UPnP device description (the XML returned from a
|
||||
* discovered LOCATION URL). Carries the bits we actually need for
|
||||
* the picker: friendlyName / manufacturer / modelName for display,
|
||||
* AVTransport + RenderingControl control URLs for command dispatch.
|
||||
*
|
||||
* Filtered to MediaRenderer-capable devices — anything without an
|
||||
* AVTransport service control URL is dropped by [parse] returning
|
||||
* null (we can't make it play).
|
||||
*/
|
||||
data class DeviceDescription(
|
||||
val udn: String,
|
||||
val friendlyName: String,
|
||||
val manufacturer: String,
|
||||
val modelName: String,
|
||||
val avTransportControlUrl: HttpUrl,
|
||||
val renderingControlUrl: HttpUrl?,
|
||||
val zoneGroupTopologyControlUrl: HttpUrl? = null,
|
||||
) {
|
||||
companion object {
|
||||
private const val AVT_SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
|
||||
private const val RC_SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
|
||||
private const val ZGT_SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
|
||||
|
||||
private const val TAG_SERVICE = "service"
|
||||
private const val TAG_UDN = "UDN"
|
||||
private const val TAG_FRIENDLY_NAME = "friendlyName"
|
||||
private const val TAG_MANUFACTURER = "manufacturer"
|
||||
private const val TAG_MODEL_NAME = "modelName"
|
||||
private const val TAG_SERVICE_TYPE = "serviceType"
|
||||
private const val TAG_CONTROL_URL = "controlURL"
|
||||
|
||||
/**
|
||||
* Parse [xml] (the body fetched from the SSDP LOCATION URL),
|
||||
* resolving relative service control URLs against [base].
|
||||
* Returns null when AVTransport is missing — we have no way
|
||||
* to control the device without it.
|
||||
*/
|
||||
fun parse(xml: String, base: HttpUrl): DeviceDescription? {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
setInput(xml.reader())
|
||||
}
|
||||
val acc = ParseState()
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
handleEvent(parser, acc, base)
|
||||
parser.next()
|
||||
}
|
||||
val avt = acc.avtControlUrl ?: return null
|
||||
return DeviceDescription(
|
||||
udn = acc.udn,
|
||||
friendlyName = acc.friendlyName,
|
||||
manufacturer = acc.manufacturer,
|
||||
modelName = acc.modelName,
|
||||
avTransportControlUrl = avt,
|
||||
renderingControlUrl = acc.rcControlUrl,
|
||||
zoneGroupTopologyControlUrl = acc.zgtControlUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleEvent(parser: XmlPullParser, acc: ParseState, base: HttpUrl) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> handleStartTag(parser, acc)
|
||||
XmlPullParser.END_TAG -> handleEndTag(parser, acc, base)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleStartTag(parser: XmlPullParser, acc: ParseState) {
|
||||
when (parser.name) {
|
||||
TAG_SERVICE -> {
|
||||
acc.inService = true
|
||||
acc.serviceType = ""
|
||||
acc.serviceControlUrl = ""
|
||||
}
|
||||
TAG_UDN -> acc.udn = parser.nextTextSafe()
|
||||
TAG_FRIENDLY_NAME -> acc.friendlyName = parser.nextTextSafe()
|
||||
TAG_MANUFACTURER -> acc.manufacturer = parser.nextTextSafe()
|
||||
TAG_MODEL_NAME -> acc.modelName = parser.nextTextSafe()
|
||||
TAG_SERVICE_TYPE -> if (acc.inService) acc.serviceType = parser.nextTextSafe()
|
||||
TAG_CONTROL_URL -> if (acc.inService) acc.serviceControlUrl = parser.nextTextSafe()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEndTag(parser: XmlPullParser, acc: ParseState, base: HttpUrl) {
|
||||
if (parser.name != TAG_SERVICE) return
|
||||
val resolved = resolveControlUrl(base, acc.serviceControlUrl)
|
||||
when (acc.serviceType) {
|
||||
AVT_SERVICE_TYPE -> acc.avtControlUrl = resolved
|
||||
RC_SERVICE_TYPE -> acc.rcControlUrl = resolved
|
||||
ZGT_SERVICE_TYPE -> acc.zgtControlUrl = resolved
|
||||
}
|
||||
acc.inService = false
|
||||
}
|
||||
|
||||
private fun resolveControlUrl(base: HttpUrl, path: String): HttpUrl? {
|
||||
if (path.isBlank()) return null
|
||||
return path.toHttpUrlOrNull() ?: base.resolve(path)
|
||||
}
|
||||
|
||||
private fun XmlPullParser.nextTextSafe(): String =
|
||||
runCatching { nextText() }.getOrDefault("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable accumulator used during pull-parsing. Lives only for the
|
||||
* duration of one [parse] call.
|
||||
*/
|
||||
private class ParseState {
|
||||
var udn: String = ""
|
||||
var friendlyName: String = ""
|
||||
var manufacturer: String = ""
|
||||
var modelName: String = ""
|
||||
var avtControlUrl: HttpUrl? = null
|
||||
var rcControlUrl: HttpUrl? = null
|
||||
var zgtControlUrl: HttpUrl? = null
|
||||
var inService: Boolean = false
|
||||
var serviceType: String = ""
|
||||
var serviceControlUrl: String = ""
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* RenderingControl service wrapper for hardware-volume routing while a
|
||||
* UPnP route is active. GetVolume seeds an in-memory cache; SetVolume
|
||||
* is invoked by NowPlayingScreen's volume-key interceptor. Clamps to
|
||||
* the UPnP-standard 0..100 range.
|
||||
*/
|
||||
class RenderingControlClient(
|
||||
private val soap: SoapClient,
|
||||
private val controlUrl: HttpUrl,
|
||||
) {
|
||||
suspend fun getVolume(channel: String = "Master"): Int {
|
||||
val args = soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "GetVolume",
|
||||
args = mapOf("InstanceID" to "0", "Channel" to channel),
|
||||
)
|
||||
return args["CurrentVolume"]?.toIntOrNull() ?: 0
|
||||
}
|
||||
|
||||
suspend fun setVolume(volume: Int, channel: String = "Master") {
|
||||
val clamped = volume.coerceIn(VOLUME_MIN, VOLUME_MAX)
|
||||
soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "SetVolume",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"Channel" to channel,
|
||||
"DesiredVolume" to clamped.toString(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:RenderingControl:1"
|
||||
const val VOLUME_MIN = 0
|
||||
const val VOLUME_MAX = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than
|
||||
* pulled in via jupnp; we control every line and integrate cleanly
|
||||
* with the app's OkHttpClient (shared connection pool, timeouts).
|
||||
*
|
||||
* Builds the standard SOAP 1.1 envelope, POSTs with the required
|
||||
* SOAPACTION + Content-Type headers, returns the parsed
|
||||
* `<action>Response` element as a Map<String, String> (UPnP responses
|
||||
* are flat string maps).
|
||||
*
|
||||
* Throws [SoapFaultException] on a `<s:Fault>` response (the UPnP
|
||||
* device's way of saying "I rejected your request"). Other transport
|
||||
* errors propagate as IOException.
|
||||
*/
|
||||
class SoapClient(
|
||||
private val okHttp: OkHttpClient,
|
||||
private val onRawResponse: ((action: String, body: String) -> Unit)? = null,
|
||||
) {
|
||||
suspend fun call(
|
||||
controlUrl: HttpUrl,
|
||||
serviceType: String,
|
||||
action: String,
|
||||
args: Map<String, String> = emptyMap(),
|
||||
): Map<String, String> = withContext(Dispatchers.IO) {
|
||||
val envelope = buildEnvelope(serviceType, action, args)
|
||||
val request = Request.Builder()
|
||||
.url(controlUrl)
|
||||
.post(envelope.toRequestBody(null))
|
||||
.header("Content-Type", SOAP_CONTENT_TYPE)
|
||||
.header("SOAPACTION", "\"$serviceType#$action\"")
|
||||
.build()
|
||||
okHttp.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string().orEmpty()
|
||||
onRawResponse?.invoke(action, body)
|
||||
if (!response.isSuccessful) {
|
||||
throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body))
|
||||
}
|
||||
parseResponseArgs(body, action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildEnvelope(
|
||||
serviceType: String,
|
||||
action: String,
|
||||
args: Map<String, String>,
|
||||
): String = buildString {
|
||||
append("<?xml version=\"1.0\"?>")
|
||||
append("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"")
|
||||
append(" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">")
|
||||
append("<s:Body>")
|
||||
append("<u:").append(action)
|
||||
append(" xmlns:u=\"").append(serviceType).append("\">")
|
||||
args.forEach { (k, v) ->
|
||||
append("<").append(k).append(">")
|
||||
append(xmlEscape(v))
|
||||
append("</").append(k).append(">")
|
||||
}
|
||||
append("</u:").append(action).append(">")
|
||||
append("</s:Body>")
|
||||
append("</s:Envelope>")
|
||||
}
|
||||
|
||||
private fun parseResponseArgs(body: String, action: String): Map<String, String> {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
setInput(body.reader())
|
||||
}
|
||||
val responseTag = "${action}Response"
|
||||
val args = mutableMapOf<String, String>()
|
||||
var inResponse = false
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
inResponse = handleParserEvent(parser, responseTag, inResponse, args)
|
||||
parser.next()
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
private fun handleParserEvent(
|
||||
parser: XmlPullParser,
|
||||
responseTag: String,
|
||||
inResponse: Boolean,
|
||||
args: MutableMap<String, String>,
|
||||
): Boolean = when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
if (parser.name == responseTag) {
|
||||
true
|
||||
} else {
|
||||
if (inResponse) {
|
||||
val name = parser.name
|
||||
val text = readElementContent(parser, name)
|
||||
args[name] = text
|
||||
}
|
||||
inResponse
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> if (parser.name == responseTag) false else inResponse
|
||||
else -> inResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the content of the currently-started element. Try nextText() first
|
||||
* (works when the content is text -- escaped XML included). If that throws
|
||||
* (because the content is nested elements), manually walk to the matching
|
||||
* END_TAG, accumulating text and re-serializing child elements.
|
||||
*
|
||||
* Some Sonos firmware sends the GetZoneGroupState payload as nested XML
|
||||
* elements without escaping; this fallback recovers that path.
|
||||
*/
|
||||
private fun readElementContent(parser: XmlPullParser, tagName: String): String {
|
||||
return runCatching { parser.nextText() }.getOrElse {
|
||||
readUntilEndTag(parser, tagName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readUntilEndTag(parser: XmlPullParser, tagName: String): String {
|
||||
val sb = StringBuilder()
|
||||
var depth = 1
|
||||
var done = false
|
||||
while (!done && depth > 0) {
|
||||
when (parser.next()) {
|
||||
XmlPullParser.START_TAG -> {
|
||||
appendStartTag(sb, parser)
|
||||
depth += 1
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
depth -= 1
|
||||
if (depth == 0 && parser.name == tagName) {
|
||||
done = true
|
||||
} else {
|
||||
sb.append("</").append(parser.name).append('>')
|
||||
}
|
||||
}
|
||||
XmlPullParser.TEXT -> sb.append(parser.text.orEmpty())
|
||||
XmlPullParser.END_DOCUMENT -> done = true
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun appendStartTag(sb: StringBuilder, parser: XmlPullParser) {
|
||||
sb.append('<').append(parser.name)
|
||||
for (i in 0 until parser.attributeCount) {
|
||||
sb.append(' ')
|
||||
.append(parser.getAttributeName(i))
|
||||
.append("=\"")
|
||||
.append(parser.getAttributeValue(i))
|
||||
.append('"')
|
||||
}
|
||||
sb.append('>')
|
||||
}
|
||||
|
||||
private fun faultCodeOf(body: String): String =
|
||||
extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown"
|
||||
|
||||
private fun faultDescriptionOf(body: String): String =
|
||||
extractBetween(body, "<errorDescription>", "</errorDescription>").orEmpty()
|
||||
|
||||
private fun extractBetween(body: String, open: String, close: String): String? {
|
||||
val start = body.indexOf(open)
|
||||
if (start < 0) return null
|
||||
val contentStart = start + open.length
|
||||
val end = body.indexOf(close, contentStart)
|
||||
return if (end < 0) null else body.substring(contentStart, end)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SOAP_CONTENT_TYPE = "text/xml; charset=\"utf-8\""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a UPnP device responds with `<s:Fault>` — typically wraps
|
||||
* a UPnPError with `errorCode` + `errorDescription`. Code is preserved
|
||||
* as a string (UPnP codes are numeric in spec but we don't constrain).
|
||||
*/
|
||||
class SoapFaultException(val code: String, val description: String) :
|
||||
Exception("SOAP fault $code: $description")
|
||||
|
||||
private const val MAX_DIAGNOSTIC_RESPONSES = 6 // 3 polls x 2 action types
|
||||
private const val MAX_BODY_LOG_CHARS = 2048
|
||||
|
||||
/**
|
||||
* Returns a [SoapClient] that logs the raw response body for the first
|
||||
* [MAX_DIAGNOSTIC_RESPONSES] calls for actions in [DIAGNOSTIC_ACTIONS]
|
||||
* (GetPositionInfo, GetTransportInfo, GetZoneGroupState). After that the
|
||||
* callback is a no-op so there is no persistent log spam. Logged at WARN
|
||||
* so release builds capture it without a separate log-level override.
|
||||
*/
|
||||
internal fun loggingSoapClient(okHttp: OkHttpClient, label: String): SoapClient {
|
||||
val counter = AtomicInteger(0)
|
||||
return SoapClient(okHttp) { action, body ->
|
||||
if (action !in DIAGNOSTIC_ACTIONS) return@SoapClient
|
||||
val n = counter.incrementAndGet()
|
||||
if (n <= MAX_DIAGNOSTIC_RESPONSES) {
|
||||
Timber.w(
|
||||
"UPnP %s response #%d (%s): %s",
|
||||
label, n, action,
|
||||
body.take(MAX_BODY_LOG_CHARS),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val DIAGNOSTIC_ACTIONS = setOf(
|
||||
"GetPositionInfo",
|
||||
"GetTransportInfo",
|
||||
"GetZoneGroupState",
|
||||
)
|
||||
|
||||
/** XML-escapes a string value for embedding as text content inside a SOAP envelope. */
|
||||
internal fun xmlEscape(v: String): String = v
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import androidx.core.content.getSystemService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.DatagramPacket
|
||||
import java.net.InetAddress
|
||||
import java.net.MulticastSocket
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
/**
|
||||
* UDP multicast SSDP listener + M-SEARCH sender. Emits each discovery
|
||||
* response's LOCATION URL on the [discoveries] SharedFlow; the
|
||||
* `UpnpDiscoveryController` follows up by fetching + parsing each
|
||||
* device description.
|
||||
*
|
||||
* Passive listen is always-on once [start] is called (NOTIFY packets
|
||||
* speakers send when they boot / refresh). Active discovery (an
|
||||
* explicit M-SEARCH request) is triggered by [requestActiveScan] —
|
||||
* called when the picker sheet opens so newly-paired devices appear
|
||||
* promptly.
|
||||
*
|
||||
* WifiManager.MulticastLock is held while the listener is running.
|
||||
* Released by [stop] / cancellation of the parent scope.
|
||||
*/
|
||||
class SsdpDiscovery(
|
||||
private val context: Context,
|
||||
) {
|
||||
private val discoveriesInternal =
|
||||
MutableSharedFlow<String>(extraBufferCapacity = DISCOVERY_BUFFER_CAPACITY)
|
||||
val discoveries: SharedFlow<String> = discoveriesInternal.asSharedFlow()
|
||||
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
private var socket: MulticastSocket? = null
|
||||
private var listenJob: Job? = null
|
||||
|
||||
fun start(scope: CoroutineScope) {
|
||||
if (listenJob != null) return
|
||||
val wifi = context.getSystemService<WifiManager>() ?: return
|
||||
multicastLock = wifi.createMulticastLock(MULTICAST_LOCK_TAG).apply {
|
||||
setReferenceCounted(false)
|
||||
acquire()
|
||||
}
|
||||
val sock = MulticastSocket(ANY_LOCAL_PORT).apply {
|
||||
joinGroup(InetAddress.getByName(SSDP_MULTICAST_ADDR))
|
||||
}
|
||||
socket = sock
|
||||
listenJob = scope.launch(Dispatchers.IO) {
|
||||
val buf = ByteArray(SOCKET_READ_BUFFER_BYTES)
|
||||
val packet = DatagramPacket(buf, buf.size)
|
||||
while (isActive) {
|
||||
runCatching { sock.receive(packet) }.onSuccess {
|
||||
val raw = String(packet.data, 0, packet.length, StandardCharsets.UTF_8)
|
||||
parseLocation(raw)?.let { discoveriesInternal.tryEmit(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
listenJob?.cancel()
|
||||
listenJob = null
|
||||
runCatching { socket?.close() }
|
||||
socket = null
|
||||
runCatching { multicastLock?.release() }
|
||||
multicastLock = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an M-SEARCH packet asking for MediaRenderer:1 devices.
|
||||
* Responses arrive on the listener socket and emit via
|
||||
* [discoveries] as their LOCATION URL.
|
||||
*/
|
||||
suspend fun requestActiveScan() = withContext(Dispatchers.IO) {
|
||||
val sock = socket ?: return@withContext
|
||||
val payload = buildMSearchPayload().toByteArray(StandardCharsets.UTF_8)
|
||||
val packet = DatagramPacket(
|
||||
payload,
|
||||
payload.size,
|
||||
InetAddress.getByName(SSDP_MULTICAST_ADDR),
|
||||
SSDP_PORT,
|
||||
)
|
||||
runCatching { sock.send(packet) }
|
||||
Unit
|
||||
}
|
||||
|
||||
private fun buildMSearchPayload(): String = buildString {
|
||||
append("M-SEARCH * HTTP/1.1\r\n")
|
||||
append("HOST: ").append(SSDP_MULTICAST_ADDR).append(":").append(SSDP_PORT).append("\r\n")
|
||||
append("MAN: \"ssdp:discover\"\r\n")
|
||||
append("MX: ").append(MSEARCH_MX_SECONDS).append("\r\n")
|
||||
append("ST: ").append(MEDIA_RENDERER_TARGET).append("\r\n")
|
||||
append("\r\n")
|
||||
}
|
||||
|
||||
private fun parseLocation(raw: String): String? {
|
||||
raw.lineSequence().forEach { line ->
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.startsWith(LOCATION_HEADER, ignoreCase = true)) {
|
||||
return trimmed.substring(LOCATION_HEADER.length).trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SSDP_MULTICAST_ADDR = "239.255.255.250"
|
||||
const val SSDP_PORT = 1900
|
||||
const val ANY_LOCAL_PORT = 0
|
||||
const val SOCKET_READ_BUFFER_BYTES = 4096
|
||||
const val DISCOVERY_BUFFER_CAPACITY = 32
|
||||
const val MSEARCH_MX_SECONDS = 2
|
||||
const val MULTICAST_LOCK_TAG = "minstrel.upnp.ssdp"
|
||||
const val MEDIA_RENDERER_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:1"
|
||||
const val LOCATION_HEADER = "LOCATION:"
|
||||
}
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
@file:Suppress("TooManyFunctions") // discovery + Sonos topology + transport-lookup density
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import android.content.Context
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.player.output.upnp.sonos.SonosZoneGroup
|
||||
import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Owns the SSDP listener lifecycle, fetches each discovered LOCATION's
|
||||
* device description XML, and projects discovered MediaRenderers into a
|
||||
* `StateFlow<List<UpnpRoute>>` that [OutputPickerController] merges with
|
||||
* system routes.
|
||||
*
|
||||
* Passive listen runs from process start (via `init`). Active discovery
|
||||
* (an M-SEARCH burst) is triggered by [upgradeDiscovery] when the
|
||||
* picker sheet opens; SSDP has no symmetric "downgrade" — the passive
|
||||
* NOTIFY listener stays on for the process lifetime, so
|
||||
* [downgradeDiscovery] is a no-op preserved for API parity with the
|
||||
* MediaRouter-side controller.
|
||||
*
|
||||
* Discovered devices are de-duplicated by UDN: a repeated NOTIFY for
|
||||
* the same speaker replaces the prior entry rather than appending a
|
||||
* duplicate row to the picker.
|
||||
*/
|
||||
@Singleton
|
||||
class UpnpDiscoveryController @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
@ApplicationScope private val appScope: CoroutineScope,
|
||||
private val okHttp: OkHttpClient,
|
||||
) {
|
||||
private val ssdp = SsdpDiscovery(context)
|
||||
|
||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||
|
||||
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
||||
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||
|
||||
init {
|
||||
ssdp.start(appScope)
|
||||
// appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
|
||||
// so the launched collector dies with the process — no explicit
|
||||
// cancellation needed.
|
||||
appScope.launch(Dispatchers.IO) {
|
||||
ssdp.discoveries.collect { locationUrl -> handleDiscovery(locationUrl) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire an M-SEARCH burst so newly-paired speakers appear within a
|
||||
* few seconds rather than waiting for the next NOTIFY beacon
|
||||
* (typical SSDP cadence: every ~30min — far too slow for a UI
|
||||
* that just opened).
|
||||
*/
|
||||
fun upgradeDiscovery() {
|
||||
appScope.launch { ssdp.requestActiveScan() }
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op for the SSDP socket — passive NOTIFY listen is always on
|
||||
* once [init] has run. Kept symmetric with the MediaRouter side's
|
||||
* `downgradeDiscovery` so the picker controller fans out to both
|
||||
* without conditional logic.
|
||||
*/
|
||||
fun downgradeDiscovery() {
|
||||
// intentionally empty: see kdoc
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an [AVTransportClient] bound to the previously-discovered
|
||||
* route's control URL. Returns null when the routeId isn't in the
|
||||
* current snapshot — the speaker disappeared between picker open
|
||||
* and tap, or the caller passed a non-UPnP routeId. Callers handle
|
||||
* the null by abandoning the selection (no crash, no fallback).
|
||||
*/
|
||||
fun transportFor(routeId: String): AVTransportClient? {
|
||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||
return AVTransportClient(
|
||||
loggingSoapClient(okHttp, route.name),
|
||||
route.avTransportControlUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun handleDiscovery(locationUrl: String) {
|
||||
val route = fetchRoute(locationUrl) ?: return
|
||||
upsertRoute(route)
|
||||
if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
|
||||
refreshSonosTopology(route)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertRoute(route: UpnpRoute) {
|
||||
val current = routesInternal.value
|
||||
val idx = current.indexOfFirst { it.id == route.id }
|
||||
routesInternal.value = if (idx < 0) {
|
||||
current + route
|
||||
} else {
|
||||
current.toMutableList().also { it[idx] = route }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshSonosTopology(anySonos: UpnpRoute) {
|
||||
val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: run {
|
||||
Timber.w(
|
||||
"Sonos %s has no ZoneGroupTopology URL -- topology grouping disabled",
|
||||
anySonos.id,
|
||||
)
|
||||
return
|
||||
}
|
||||
val groups = runCatching {
|
||||
ZoneGroupTopologyClient(
|
||||
loggingSoapClient(okHttp, "ZGT-${anySonos.id}"),
|
||||
zgtUrl,
|
||||
).getZoneGroupState()
|
||||
}
|
||||
.onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) }
|
||||
.getOrNull() ?: return
|
||||
Timber.w(
|
||||
"Sonos topology refreshed for %s: %d group(s)",
|
||||
anySonos.id,
|
||||
groups.size,
|
||||
)
|
||||
sonosTopologyInternal.value = groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the coordinator UDN's full route for the group [udn] belongs
|
||||
* to, or null if topology hasn't loaded / the udn is in no group. UDN
|
||||
* normalization strips the "uuid:" prefix because DeviceDescription's
|
||||
* <UDN> carries it but Sonos's ZoneGroupState Coordinator attr does not.
|
||||
*/
|
||||
fun coordinatorRouteFor(udn: String): UpnpRoute? {
|
||||
val bare = udn.bareUdn()
|
||||
val coord = sonosTopologyInternal.value
|
||||
.firstOrNull { g -> g.members.any { it.udn.bareUdn() == bare } }
|
||||
?.coordinatorUdn ?: return null
|
||||
return routesInternal.value.firstOrNull { it.id.bareUdn() == coord.bareUdn() }
|
||||
}
|
||||
|
||||
/**
|
||||
* UDNs of every NON-coordinator Sonos group member. The picker
|
||||
* controller suppresses these from the visible list.
|
||||
*/
|
||||
fun nonCoordinatorMemberUdns(): Set<String> {
|
||||
return sonosTopologyInternal.value.flatMap { g ->
|
||||
g.members.map { it.udn.bareUdn() }
|
||||
.filter { it != g.coordinatorUdn.bareUdn() }
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + parse the device description at [locationUrl] into a
|
||||
* [UpnpRoute]. Returns null on URL parse failure, transport
|
||||
* failure, empty body, or non-renderer device. Single-return form
|
||||
* (chained `let`s + an early null guard) so detekt's default
|
||||
* ReturnCount cap is respected.
|
||||
*/
|
||||
private fun fetchRoute(locationUrl: String): UpnpRoute? {
|
||||
val url = locationUrl.toHttpUrlOrNull() ?: return null
|
||||
return fetchBody(url)
|
||||
?.let { DeviceDescription.parse(it, url) }
|
||||
?.let { desc ->
|
||||
UpnpRoute(
|
||||
id = desc.udn,
|
||||
name = displayName(desc.friendlyName, desc.manufacturer),
|
||||
manufacturer = desc.manufacturer,
|
||||
modelName = desc.modelName,
|
||||
avTransportControlUrl = desc.avTransportControlUrl,
|
||||
renderingControlUrl = desc.renderingControlUrl,
|
||||
zoneGroupTopologyControlUrl = desc.zoneGroupTopologyControlUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the raw UPnP friendlyName for picker display. Sonos uses
|
||||
* the format `Room - Device Type - RINCON_<UDN>`; we strip
|
||||
* everything after the first " - " so the chip shows just "Living
|
||||
* Room" / "Kitchen" / etc. Generic UPnP devices (Yamaha, Samsung
|
||||
* TV, etc.) often append a "(192.168.x.x)" IP suffix; strip that
|
||||
* too. Empty / blank → "Network speaker" fallback.
|
||||
*
|
||||
* Device subtitle (manufacturer + model) is rendered separately
|
||||
* by the picker sheet, so room-only here doesn't lose information.
|
||||
*/
|
||||
private fun displayName(friendlyName: String, manufacturer: String): String {
|
||||
val trimmed = friendlyName.trim()
|
||||
if (trimmed.isBlank()) return "Network speaker"
|
||||
val byVendor = when {
|
||||
manufacturer.contains("Sonos", ignoreCase = true) -> {
|
||||
trimmed.substringBefore(" - ", trimmed)
|
||||
}
|
||||
else -> trimmed
|
||||
}
|
||||
val withoutIpSuffix = byVendor.replace(IP_SUFFIX_REGEX, "").trim()
|
||||
return withoutIpSuffix.ifBlank { "Network speaker" }
|
||||
}
|
||||
|
||||
private fun fetchBody(url: HttpUrl): String? {
|
||||
val body = runCatching {
|
||||
okHttp.newCall(Request.Builder().url(url).build()).execute().use {
|
||||
it.body?.string()
|
||||
}
|
||||
}.getOrNull().orEmpty()
|
||||
return body.ifEmpty { null }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
// " (192.168.0.77)" trailing host suffix some generic UPnP
|
||||
// devices append. Anchored to end-of-string so it never eats
|
||||
// a legitimate parenthetical inside a name.
|
||||
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a Sonos UDN to its bare RINCON form for cross-comparison.
|
||||
*
|
||||
* Three places use UDN strings with different conventions:
|
||||
* - DeviceDescription's <UDN> tag carries the `uuid:` prefix.
|
||||
* - Sonos exposes one UDN per embedded device. The MediaRenderer adds
|
||||
* a `_MR` suffix and the MediaServer adds `_MS`.
|
||||
* - The ZGT GetZoneGroupState response uses bare `RINCON_xxx` with
|
||||
* neither the `uuid:` prefix nor any device suffix.
|
||||
*
|
||||
* To compare any pair of those, strip the prefix and the suffix so
|
||||
* everything reduces to the underlying speaker identity.
|
||||
*/
|
||||
internal fun String.bareUdn(): String = removePrefix("uuid:")
|
||||
.removeSuffix("_MR")
|
||||
.removeSuffix("_MS")
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* A discovered UPnP / DLNA MediaRenderer (Sonos, Yamaha MusicCast,
|
||||
* Bose SoundTouch, generic DLNA renderers). Lifted out of the SOAP /
|
||||
* SSDP details so the picker UI consumes a narrow domain shape.
|
||||
*
|
||||
* Sonos devices additionally populate [zoneGroupTopologyControlUrl] from the
|
||||
* ZoneGroupTopology service in their device description; non-Sonos devices
|
||||
* leave it null. The discovery controller uses that URL to aggregate stereo
|
||||
* pairs and multi-speaker groups into single picker rows.
|
||||
*
|
||||
* [id] is the device UDN (e.g. `uuid:RINCON_ABC...`). [name] is the
|
||||
* raw `<friendlyName>` straight from the device description — callers
|
||||
* fall back to "Network speaker" upstream when it's blank; this class
|
||||
* does not perform that substitution itself.
|
||||
*/
|
||||
data class UpnpRoute(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val manufacturer: String,
|
||||
val modelName: String,
|
||||
val avTransportControlUrl: HttpUrl,
|
||||
val renderingControlUrl: HttpUrl?,
|
||||
val zoneGroupTopologyControlUrl: HttpUrl? = null,
|
||||
)
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp.sonos
|
||||
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
|
||||
/**
|
||||
* One Sonos zone group as exposed by ZoneGroupTopology.GetZoneGroupState.
|
||||
* Stereo pairs and multi-speaker groups all appear as one group with
|
||||
* multiple members; one member is the coordinator we send SOAP to.
|
||||
*/
|
||||
data class SonosZoneGroup(
|
||||
val coordinatorUdn: String,
|
||||
val name: String,
|
||||
val members: List<SonosZoneMember>,
|
||||
)
|
||||
|
||||
data class SonosZoneMember(
|
||||
val udn: String,
|
||||
val roomName: String,
|
||||
val location: String?,
|
||||
val channelMapSet: String?,
|
||||
)
|
||||
|
||||
object SonosTopology {
|
||||
|
||||
fun parse(xml: String): List<SonosZoneGroup> {
|
||||
val effective = if (xml.contains("<ZoneGroup")) {
|
||||
unescapeXmlEntities(xml)
|
||||
} else {
|
||||
xml
|
||||
}
|
||||
return runCatching { parseStrict(effective) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun unescapeXmlEntities(s: String): String = s
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&") // must be last to avoid double-decoding
|
||||
|
||||
private fun parseStrict(xml: String): List<SonosZoneGroup> {
|
||||
val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
|
||||
setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
|
||||
setInput(xml.reader())
|
||||
}
|
||||
val groups = mutableListOf<SonosZoneGroup>()
|
||||
var currentCoordinator: String? = null
|
||||
var currentMembers: MutableList<SonosZoneMember>? = null
|
||||
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
|
||||
when (parser.eventType) {
|
||||
XmlPullParser.START_TAG -> when (parser.name) {
|
||||
TAG_ZONE_GROUP -> {
|
||||
currentCoordinator = parser.getAttributeValue(null, ATTR_COORDINATOR)
|
||||
currentMembers = mutableListOf()
|
||||
}
|
||||
TAG_ZONE_GROUP_MEMBER -> currentMembers?.add(memberOf(parser))
|
||||
}
|
||||
XmlPullParser.END_TAG -> if (parser.name == TAG_ZONE_GROUP) {
|
||||
val members = currentMembers ?: emptyList()
|
||||
val coord = currentCoordinator
|
||||
if (coord != null && members.isNotEmpty()) {
|
||||
val name = members.firstOrNull { it.udn == coord }?.roomName
|
||||
?: members.first().roomName
|
||||
groups.add(SonosZoneGroup(coord, name, members))
|
||||
}
|
||||
currentCoordinator = null
|
||||
currentMembers = null
|
||||
}
|
||||
}
|
||||
parser.next()
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
private fun memberOf(parser: XmlPullParser): SonosZoneMember = SonosZoneMember(
|
||||
udn = parser.getAttributeValue(null, ATTR_UUID).orEmpty(),
|
||||
roomName = parser.getAttributeValue(null, ATTR_ZONE_NAME).orEmpty(),
|
||||
location = parser.getAttributeValue(null, ATTR_LOCATION),
|
||||
channelMapSet = parser.getAttributeValue(null, ATTR_CHANNEL_MAP_SET),
|
||||
)
|
||||
|
||||
private const val TAG_ZONE_GROUP = "ZoneGroup"
|
||||
private const val TAG_ZONE_GROUP_MEMBER = "ZoneGroupMember"
|
||||
private const val ATTR_COORDINATOR = "Coordinator"
|
||||
private const val ATTR_UUID = "UUID"
|
||||
private const val ATTR_ZONE_NAME = "ZoneName"
|
||||
private const val ATTR_LOCATION = "Location"
|
||||
private const val ATTR_CHANNEL_MAP_SET = "ChannelMapSet"
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp.sonos
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||
import okhttp3.HttpUrl
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Sonos's proprietary ZoneGroupTopology service. Same SOAP shape as a
|
||||
* standard UPnP service, exposed on port 1400 at
|
||||
* /ZoneGroupTopology/Control. GetZoneGroupState returns the full
|
||||
* network topology as one XML doc wrapped inside a SOAP arg.
|
||||
*/
|
||||
class ZoneGroupTopologyClient(
|
||||
private val soap: SoapClient,
|
||||
private val controlUrl: HttpUrl,
|
||||
) {
|
||||
suspend fun getZoneGroupState(): List<SonosZoneGroup> {
|
||||
val args = soap.call(
|
||||
controlUrl = controlUrl,
|
||||
serviceType = SERVICE_TYPE,
|
||||
action = "GetZoneGroupState",
|
||||
args = emptyMap(),
|
||||
)
|
||||
val inner = args["ZoneGroupState"].orEmpty()
|
||||
Timber.w(
|
||||
"ZGT extracted ZoneGroupState (%d chars): %s",
|
||||
inner.length,
|
||||
inner.take(ZGT_LOG_TRUNCATE_CHARS),
|
||||
)
|
||||
return SonosTopology.parse(inner)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:ZoneGroupTopology:1"
|
||||
const val ZGT_LOG_TRUNCATE_CHARS = 2048
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -164,6 +165,7 @@ fun MiniPlayer(
|
||||
MiniRow(
|
||||
track = track,
|
||||
isPlaying = state.isPlaying,
|
||||
isUpnpLoading = state.isUpnpLoading,
|
||||
isLiked = isLiked,
|
||||
onExpandClick = onExpandClick,
|
||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||
@@ -204,6 +206,7 @@ private fun MiniProgressFill(positionMs: Long, durationMs: Long) {
|
||||
private fun MiniRow(
|
||||
track: TrackRef,
|
||||
isPlaying: Boolean,
|
||||
isUpnpLoading: Boolean,
|
||||
isLiked: Boolean,
|
||||
onExpandClick: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
@@ -248,15 +251,39 @@ private fun MiniRow(
|
||||
}
|
||||
LikeButton(liked = isLiked, onToggle = onToggleLike)
|
||||
TransportButton(icon = Lucide.SkipBack, description = "Previous", onClick = onPrev)
|
||||
TransportButton(
|
||||
icon = if (isPlaying) Lucide.Pause else Lucide.Play,
|
||||
description = if (isPlaying) "Pause" else "Play",
|
||||
MiniPlayPauseButton(
|
||||
isPlaying = isPlaying,
|
||||
isUpnpLoading = isUpnpLoading,
|
||||
onClick = onPlayPause,
|
||||
)
|
||||
TransportButton(icon = Lucide.SkipForward, description = "Next", onClick = onNext)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniPlayPauseButton(
|
||||
isPlaying: Boolean,
|
||||
isUpnpLoading: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
IconButton(onClick = onClick, enabled = !isUpnpLoading) {
|
||||
if (isUpnpLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(MINI_PLAY_PAUSE_SPINNER_DP.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val MINI_PLAY_PAUSE_SPINNER_DP = 24
|
||||
|
||||
@Composable
|
||||
private fun TransportButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -21,6 +26,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -44,13 +50,26 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEvent
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
@@ -66,6 +85,13 @@ import com.composables.icons.lucide.Shuffle
|
||||
import com.composables.icons.lucide.SkipBack
|
||||
import com.composables.icons.lucide.SkipForward
|
||||
import com.fabledsword.minstrel.player.RepeatMode
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||
import com.fabledsword.minstrel.player.output.DeviceChip
|
||||
import com.fabledsword.minstrel.player.output.OutputPickerSheet
|
||||
import com.fabledsword.minstrel.player.output.OutputPickerViewModel
|
||||
import com.fabledsword.minstrel.player.output.OutputRoute
|
||||
import com.fabledsword.minstrel.player.output.RouteSnapshot
|
||||
import kotlinx.coroutines.launch
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
|
||||
@@ -88,6 +114,8 @@ private const val PLAY_PAUSE_ICON_DP = 56
|
||||
private const val POP_GRACE_MS = 500L
|
||||
private val SCRUB_TRACK_HEIGHT_DP = 4.dp
|
||||
private val SCRUB_TRACK_CORNER_DP = 2.dp
|
||||
private val SCRUB_THUMB_WIDTH_DP = 4.dp
|
||||
private val SCRUB_THUMB_HEIGHT_DP = 18.dp
|
||||
|
||||
// Vertical drag-down threshold (in pixels) past which the gesture
|
||||
// pops the player. Matches Flutter's 80px threshold in spirit;
|
||||
@@ -114,27 +142,30 @@ fun NowPlayingScreen(
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.dropEvents.collect { snackbarHostState.showSnackbar("Disconnected from $it") }
|
||||
}
|
||||
val track = state.currentTrack
|
||||
if (track == null) {
|
||||
// Session torn down (queue finished + auto-stop, or user cleared
|
||||
// the queue from elsewhere). Pop back to whichever shell screen
|
||||
// launched NowPlaying rather than stranding the user on an
|
||||
// EmptyState with no escape. A short delay swallows the
|
||||
// momentary null during MediaController IPC bind on cold-mount.
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(POP_GRACE_MS)
|
||||
if (viewModel.uiState.value.currentTrack == null) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
NowPlayingNullTrackGuard(navController, viewModel)
|
||||
return
|
||||
}
|
||||
val outputViewModel: OutputPickerViewModel = hiltViewModel()
|
||||
val activeUpnp by outputViewModel.activeUpnp.collectAsStateWithLifecycle()
|
||||
val onKeyEvent = rememberUpnpVolumeKeyHandler(activeUpnp)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(activeUpnp) { if (activeUpnp != null) focusRequester.requestFocus() }
|
||||
val dominant = rememberDominantColor(track.coverUrl)
|
||||
val dismissConnection = rememberDragDismissConnection(
|
||||
onDismiss = { navController.popBackStack() },
|
||||
)
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(dismissConnection)
|
||||
.focusRequester(focusRequester)
|
||||
.focusable()
|
||||
.onKeyEvent(onKeyEvent),
|
||||
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = Color.Transparent,
|
||||
@@ -151,11 +182,32 @@ fun NowPlayingScreen(
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
trackActionsViewModel = trackActionsViewModel,
|
||||
outputViewModel = outputViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Null-track guard extracted from [NowPlayingScreen] to keep that
|
||||
* function under detekt's LongMethod ceiling. Session torn down
|
||||
* (queue finished + auto-stop, or user cleared the queue from
|
||||
* elsewhere). Pops back after a short grace delay so a momentary
|
||||
* null during MediaController IPC bind on cold-mount doesn't flash.
|
||||
*/
|
||||
@Composable
|
||||
private fun NowPlayingNullTrackGuard(
|
||||
navController: NavHostController,
|
||||
viewModel: PlayerViewModel,
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
kotlinx.coroutines.delay(POP_GRACE_MS)
|
||||
if (viewModel.uiState.value.currentTrack == null) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun dominantGradient(top: Color): Brush {
|
||||
val base = MaterialTheme.colorScheme.background
|
||||
@@ -255,6 +307,7 @@ private fun NowPlayingTopBar(onClose: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose screen wiring — layout args, not logic
|
||||
@Composable
|
||||
private fun NowPlayingBody(
|
||||
inner: androidx.compose.foundation.layout.PaddingValues,
|
||||
@@ -263,9 +316,47 @@ private fun NowPlayingBody(
|
||||
navController: NavHostController,
|
||||
viewModel: PlayerViewModel,
|
||||
trackActionsViewModel: TrackActionsViewModel,
|
||||
outputViewModel: OutputPickerViewModel,
|
||||
) {
|
||||
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
|
||||
.collectAsStateWithLifecycle(initialValue = false)
|
||||
val routes by outputViewModel.routes.collectAsStateWithLifecycle()
|
||||
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
|
||||
val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
|
||||
NowPlayingContent(
|
||||
inner = inner,
|
||||
state = state,
|
||||
track = track,
|
||||
navController = navController,
|
||||
viewModel = viewModel,
|
||||
trackActionsViewModel = trackActionsViewModel,
|
||||
routes = routes,
|
||||
isLiked = isLiked,
|
||||
onChipTapped = outputViewModel::onChipTapped,
|
||||
)
|
||||
if (sheetVisible) {
|
||||
OutputPickerSheet(
|
||||
snapshot = routes,
|
||||
permissionDenied = permissionDenied,
|
||||
onRouteSelected = outputViewModel::onRouteSelected,
|
||||
onDismiss = outputViewModel::onSheetDismissed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("LongParameterList") // mirrors NowPlayingBody — pure layout wiring
|
||||
private fun NowPlayingContent(
|
||||
inner: androidx.compose.foundation.layout.PaddingValues,
|
||||
state: com.fabledsword.minstrel.player.PlayerUiState,
|
||||
track: com.fabledsword.minstrel.models.TrackRef,
|
||||
navController: NavHostController,
|
||||
viewModel: PlayerViewModel,
|
||||
trackActionsViewModel: TrackActionsViewModel,
|
||||
routes: RouteSnapshot,
|
||||
isLiked: Boolean,
|
||||
onChipTapped: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -293,27 +384,94 @@ private fun NowPlayingBody(
|
||||
onToggleShuffle = viewModel::toggleShuffle,
|
||||
onCycleRepeat = viewModel::cycleRepeat,
|
||||
)
|
||||
// Spec visibility rule: hide the chip when the only route is the
|
||||
// built-in speaker — no picker is useful with one option. Chip
|
||||
// reappears the moment a Bluetooth pair or wired plug arrives.
|
||||
if (shouldShowChip(routes)) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
DeviceChip(
|
||||
route = routes.current,
|
||||
onClick = onChipTapped,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val smoothPositionMs by rememberSmoothPositionMs(
|
||||
positionMs = state.positionMs,
|
||||
durationMs = state.durationMs,
|
||||
isPlaying = state.isPlaying,
|
||||
)
|
||||
ScrubberRow(
|
||||
positionMs = smoothPositionMs,
|
||||
durationMs = state.durationMs,
|
||||
onSeek = viewModel::seekTo,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TransportRow(
|
||||
isPlaying = state.isPlaying,
|
||||
onPrev = viewModel::skipToPrevious,
|
||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||
onNext = viewModel::skipToNext,
|
||||
)
|
||||
PlaybackControlsBlock(state = state, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrubber + transport pair, sharing the smoothed playback position.
|
||||
* Extracted from [NowPlayingContent] to keep that body under detekt's
|
||||
* LongMethod ceiling — the two rows belong together (the scrubber's
|
||||
* smoothed position would otherwise need to be hoisted into the
|
||||
* caller just to thread it into the row below).
|
||||
*/
|
||||
@Composable
|
||||
private fun PlaybackControlsBlock(
|
||||
state: com.fabledsword.minstrel.player.PlayerUiState,
|
||||
viewModel: PlayerViewModel,
|
||||
) {
|
||||
val smoothPositionMs by rememberSmoothPositionMs(
|
||||
positionMs = state.positionMs,
|
||||
durationMs = state.durationMs,
|
||||
isPlaying = state.isPlaying,
|
||||
)
|
||||
ScrubberRow(
|
||||
positionMs = smoothPositionMs,
|
||||
durationMs = state.durationMs,
|
||||
onSeek = viewModel::seekTo,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
TransportRow(
|
||||
isPlaying = state.isPlaying,
|
||||
isUpnpLoading = state.isUpnpLoading,
|
||||
onPrev = viewModel::skipToPrevious,
|
||||
onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
|
||||
onNext = viewModel::skipToNext,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot BLUETOOTH_CONNECT permission flow tied to picker-sheet
|
||||
* visibility. The launcher is remembered across recompositions; the
|
||||
* LaunchedEffect fires when the sheet opens and the permission is not
|
||||
* already granted. Subsequent opens are no-ops once the user has
|
||||
* answered — the system remembers their choice. Returns whether the
|
||||
* user explicitly denied, so the sheet can surface the rationale row.
|
||||
*
|
||||
* Extracted from [NowPlayingBody] to keep that body under detekt's
|
||||
* LongMethod ceiling — the permission plumbing is incidental to the
|
||||
* player layout.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberBluetoothPermissionState(sheetVisible: Boolean): Boolean {
|
||||
val context = LocalContext.current
|
||||
var permissionDenied by remember { mutableStateOf(false) }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
permissionDenied = !granted
|
||||
}
|
||||
LaunchedEffect(sheetVisible) {
|
||||
if (sheetVisible &&
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
}
|
||||
}
|
||||
return permissionDenied
|
||||
}
|
||||
|
||||
private fun shouldShowChip(snapshot: RouteSnapshot): Boolean {
|
||||
val onlyBuiltIn = snapshot.available.size == 1 &&
|
||||
snapshot.available.first().kind == OutputRoute.Kind.BuiltIn
|
||||
return !onlyBuiltIn
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomActionsRow(
|
||||
navController: NavHostController,
|
||||
@@ -478,15 +636,22 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = sliderColors,
|
||||
interactionSource = interactionSource,
|
||||
// Plain filled circle to match the web client's `<input
|
||||
// type="range" accent-color>` thumb — flat, no state-layer
|
||||
// ring, no border. M3's SliderDefaults.Thumb paints a state
|
||||
// layer halo on press; we drop it for visual parity. Slider's
|
||||
// 48dp hit slop still applies, so tapability is unchanged.
|
||||
// Thin vertical pill (4dp wide × 18dp tall, fully rounded).
|
||||
// A small circle on a thin horizontal bar reads as visually
|
||||
// off-center even when geometrically aligned — the eye
|
||||
// expects the bar to bisect the thumb but a 14dp circle's
|
||||
// mass extends above and below in equal amounts that the
|
||||
// brain perceives as offset. A vertical pill the same width
|
||||
// as the track removes the ambiguity: the bar passes through
|
||||
// the pill's horizontal axis cleanly. Also matches M3's
|
||||
// expressive-slider handle direction. State-layer halo is
|
||||
// still dropped for visual parity with the web scrubber.
|
||||
// Slider's 48dp hit slop still applies, so tapability is
|
||||
// unchanged.
|
||||
thumb = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.size(width = SCRUB_THUMB_WIDTH_DP, height = SCRUB_THUMB_HEIGHT_DP)
|
||||
.clip(CircleShape)
|
||||
.background(accent),
|
||||
)
|
||||
@@ -513,12 +678,11 @@ private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Un
|
||||
|
||||
/**
|
||||
* Custom 4dp rounded scrubber track. M3's default Track is 16dp tall
|
||||
* and reads as a heavy pill rather than a measurement line; making
|
||||
* the thumb visibly taller than the track (14dp thumb on a 4dp bar)
|
||||
* restores the "handle on a string" cue your eye reads as "tool,
|
||||
* draggable." Also drops M3's stop indicator dot, which the web
|
||||
* scrubber doesn't have. Extracted so [ScrubberRow] stays under
|
||||
* detekt's LongMethod ceiling.
|
||||
* and reads as a heavy pill rather than a measurement line; the
|
||||
* pill-thumb-on-thin-track pairing restores the "handle on a string"
|
||||
* cue your eye reads as "tool, draggable." Also drops M3's stop
|
||||
* indicator dot, which the web scrubber doesn't have. Extracted so
|
||||
* [ScrubberRow] stays under detekt's LongMethod ceiling.
|
||||
*/
|
||||
@Composable
|
||||
private fun ScrubTrack(fraction: Float, accent: Color) {
|
||||
@@ -542,6 +706,7 @@ private fun ScrubTrack(fraction: Float, accent: Color) {
|
||||
@Composable
|
||||
private fun TransportRow(
|
||||
isPlaying: Boolean,
|
||||
isUpnpLoading: Boolean,
|
||||
onPrev: () -> Unit,
|
||||
onPlayPause: () -> Unit,
|
||||
onNext: () -> Unit,
|
||||
@@ -560,13 +725,20 @@ private fun TransportRow(
|
||||
modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onPlayPause) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = actionColors.primary,
|
||||
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
|
||||
)
|
||||
IconButton(onClick = onPlayPause, enabled = !isUpnpLoading) {
|
||||
if (isUpnpLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
|
||||
strokeWidth = 3.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
tint = actionColors.primary,
|
||||
modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onNext) {
|
||||
Icon(
|
||||
@@ -578,3 +750,40 @@ private fun TransportRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a key-event handler that intercepts volume-up/down when a UPnP
|
||||
* route is active and routes the step through [ActiveUpnp.rendering].
|
||||
* Volume is cached locally so rapid key presses don't each wait on a
|
||||
* getVolume() round-trip. Returns false (not consumed) for every event
|
||||
* when no UPnP route is active so the system handles volume normally.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberUpnpVolumeKeyHandler(activeUpnp: ActiveUpnp?): (KeyEvent) -> Boolean {
|
||||
val scope = rememberCoroutineScope()
|
||||
val cache = remember(activeUpnp?.routeId) { VolumeCache() }
|
||||
return remember(activeUpnp) {
|
||||
handler@{ event: KeyEvent ->
|
||||
val rc = activeUpnp?.rendering ?: return@handler false
|
||||
if (event.type != KeyEventType.KeyDown) return@handler false
|
||||
val delta = when (event.key) {
|
||||
Key.VolumeUp -> VOLUME_KEY_STEP
|
||||
Key.VolumeDown -> -VOLUME_KEY_STEP
|
||||
else -> return@handler false
|
||||
}
|
||||
scope.launch {
|
||||
val current = cache.value ?: runCatching { rc.getVolume() }.getOrNull() ?: 0
|
||||
val next = (current + delta).coerceIn(VOLUME_MIN_PERCENT, VOLUME_MAX_PERCENT)
|
||||
cache.value = next
|
||||
runCatching { rc.setVolume(next) }
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class VolumeCache(var value: Int? = null)
|
||||
|
||||
private const val VOLUME_KEY_STEP = 5
|
||||
private const val VOLUME_MIN_PERCENT = 0
|
||||
private const val VOLUME_MAX_PERCENT = 100
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.player.PlayerUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -24,6 +25,7 @@ class PlayerViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
||||
val dropEvents: SharedFlow<String> = controller.dropEvents
|
||||
|
||||
fun play() = controller.play()
|
||||
fun pause() = controller.pause()
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.fabledsword.minstrel.playlists.data
|
||||
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
private const val PLAYLIST_FETCH_TIMEOUT_MS = 8_000L
|
||||
|
||||
/**
|
||||
* Fetch a playlist and hand it to the player as a shuffled queue.
|
||||
*
|
||||
* Behavior matches the tile play-button contract used on Home and the
|
||||
* Playlists list: refreshable system playlists go through systemShuffle
|
||||
* (server-side rotation-aware order; tagging with the variant advances
|
||||
* rotation), everything else uses refreshDetail. System playlists are
|
||||
* then client-side shuffled so the tile feels random rather than
|
||||
* "start at the rotation head". User playlists keep their authored
|
||||
* order.
|
||||
*
|
||||
* Errors and empty mixes surface through [onMessage] for the caller to
|
||||
* present as a snackbar / toast / etc. Returns when the player has
|
||||
* accepted the queue (or an error path bailed).
|
||||
*/
|
||||
suspend fun playPlaylistShuffled(
|
||||
playlist: PlaylistRef,
|
||||
repository: PlaylistsRepository,
|
||||
player: PlayerController,
|
||||
onMessage: (String) -> Unit,
|
||||
) {
|
||||
val detail = fetchPlaylistDetail(playlist, repository, onMessage) ?: return
|
||||
val tracks = detail.tracks.toPlayableTrackRefs()
|
||||
if (tracks.isEmpty()) {
|
||||
onMessage("Mix isn't ready yet - try again in a moment")
|
||||
return
|
||||
}
|
||||
// Drift #564: bare systemVariant string (not "playlist:<variant>") --
|
||||
// server's rotation matcher keys on the bare variant.
|
||||
val source = if (playlist.refreshable) playlist.systemVariant else null
|
||||
// System playlist tile play button == "pick a random song + shuffle
|
||||
// the rest" UX. Server's rotation-aware order still drives rotation
|
||||
// bookkeeping via `source`; the client shuffle just removes the
|
||||
// deterministic "start at rotation head" feel.
|
||||
val ordered = if (playlist.isSystem) tracks.shuffled() else tracks
|
||||
player.setQueue(ordered, initialIndex = 0, source = source)
|
||||
}
|
||||
|
||||
private suspend fun fetchPlaylistDetail(
|
||||
playlist: PlaylistRef,
|
||||
repository: PlaylistsRepository,
|
||||
onMessage: (String) -> Unit,
|
||||
): PlaylistDetailRef? = try {
|
||||
withTimeout(PLAYLIST_FETCH_TIMEOUT_MS) {
|
||||
if (playlist.refreshable && playlist.systemVariant != null) {
|
||||
repository.systemShuffle(playlist.systemVariant)
|
||||
} else {
|
||||
repository.refreshDetail(playlist.id)
|
||||
}
|
||||
}
|
||||
} catch (
|
||||
@Suppress("SwallowedException") _: TimeoutCancellationException,
|
||||
) {
|
||||
onMessage("Couldn't load playlist - check your connection")
|
||||
null
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
onMessage("Couldn't load playlist: ${ErrorCopy.fromThrowable(e)}")
|
||||
null
|
||||
}
|
||||
+10
-2
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistWire
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Retrofit
|
||||
@@ -241,7 +242,11 @@ private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
||||
isPublic = isPublic,
|
||||
systemVariant = systemVariant,
|
||||
trackCount = trackCount,
|
||||
coverUrl = coverPath ?: "",
|
||||
// Server returns a relative cover path like "/api/playlists/<id>/cover";
|
||||
// wrap through the placeholder host so BaseUrlInterceptor + the shared
|
||||
// Coil/OkHttpClient resolve it against the live server. Without this
|
||||
// wrap AsyncImage silently fails on the relative URL.
|
||||
coverUrl = resolveServerUrl(coverPath) ?: "",
|
||||
)
|
||||
|
||||
private fun PlaylistWire.toEntity(): CachedPlaylistEntity =
|
||||
@@ -277,7 +282,10 @@ private fun PlaylistDetailWire.toPlaylistRef(): PlaylistRef =
|
||||
isPublic = isPublic,
|
||||
systemVariant = systemVariant,
|
||||
trackCount = trackCount,
|
||||
coverUrl = coverUrl,
|
||||
// Same relative-path wrap as CachedPlaylistEntity.toDomain — the wire
|
||||
// gives us /api/playlists/<id>/cover and Coil needs the placeholder
|
||||
// host for BaseUrlInterceptor to rewrite to the live server.
|
||||
coverUrl = resolveServerUrl(coverUrl) ?: "",
|
||||
ownerUsername = ownerUsername,
|
||||
)
|
||||
|
||||
|
||||
+1
-3
@@ -87,7 +87,6 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -141,8 +140,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
||||
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
likes.observeLikedTracks()
|
||||
.map { tracks -> tracks.mapTo(mutableSetOf()) { it.id } }
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
|
||||
+64
-2
@@ -13,9 +13,13 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -23,11 +27,14 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||
import com.fabledsword.minstrel.nav.Playlists
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.playPlaylistShuffled
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||
@@ -37,12 +44,19 @@ import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||
@@ -50,9 +64,25 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class PlaylistsListViewModel @Inject constructor(
|
||||
private val repository: PlaylistsRepository,
|
||||
private val player: PlayerController,
|
||||
private val eventsStream: EventsStream,
|
||||
connectivity: ConnectivityObserver,
|
||||
) : ViewModel() {
|
||||
|
||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
||||
|
||||
/** Transient snackbar messages from playlist tile play taps. */
|
||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||
|
||||
/** True when the device has no usable internet -- gates refreshable system tiles. */
|
||||
val offline: StateFlow<Boolean> = connectivity.online
|
||||
.map { !it }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// Live updates: a playlist created/updated/deleted from another
|
||||
@@ -69,6 +99,15 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
runCatching { repository.refreshList() }
|
||||
}
|
||||
|
||||
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||
viewModelScope.launch {
|
||||
playPlaylistShuffled(playlist, repository, player) {
|
||||
poolMessages.trySend(it)
|
||||
}
|
||||
}.join()
|
||||
}
|
||||
|
||||
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||
repository.observeAll()
|
||||
.map { list ->
|
||||
@@ -88,6 +127,10 @@ fun PlaylistsListScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: PlaylistsListViewModel = hiltViewModel(),
|
||||
) {
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.transientMessages.collect { snackbar.showSnackbar(it) }
|
||||
}
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
@@ -97,8 +140,10 @@ fun PlaylistsListScreen(
|
||||
currentRouteName = Playlists::class.qualifiedName,
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
) { inner ->
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
@@ -117,7 +162,9 @@ fun PlaylistsListScreen(
|
||||
)
|
||||
is UiState.Success -> PlaylistsGrid(
|
||||
playlists = s.data,
|
||||
offline = offline,
|
||||
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
||||
onPlay = viewModel::playPlaylist,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -127,7 +174,9 @@ fun PlaylistsListScreen(
|
||||
@Composable
|
||||
private fun PlaylistsGrid(
|
||||
playlists: List<PlaylistRef>,
|
||||
offline: Boolean,
|
||||
onPlaylistClick: (String) -> Unit,
|
||||
onPlay: suspend (PlaylistRef) -> Unit,
|
||||
) {
|
||||
val systemPlaylists = playlists.filter { it.isSystem }
|
||||
val userPlaylists = playlists.filter { !it.isSystem }
|
||||
@@ -142,7 +191,15 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("System playlists")
|
||||
}
|
||||
items(items = systemPlaylists, key = { it.id }) { playlist ->
|
||||
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
// Refreshable system tiles need server endpoints (systemShuffle);
|
||||
// disable when offline. Empty mixes show a snackbar after tap.
|
||||
playEnabled = playlist.trackCount > 0 &&
|
||||
!(offline && playlist.refreshable),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (userPlaylists.isNotEmpty()) {
|
||||
@@ -150,7 +207,12 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("Your playlists")
|
||||
}
|
||||
items(items = userPlaylists, key = { it.id }) { playlist ->
|
||||
PlaylistCard(playlist = playlist, onClick = { onPlaylistClick(playlist.id) })
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
playEnabled = playlist.trackCount > 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.fabledsword.minstrel.search.data
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.SearchApi
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.library.data.toDomain
|
||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||
import retrofit2.Retrofit
|
||||
@@ -8,17 +13,34 @@ import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val LOCAL_SEARCH_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Thin Retrofit wrapper around `/api/search`. Debouncing lives in
|
||||
* the ViewModel, not here, so the repository stays trivial.
|
||||
* Cache-first when offline. When [ServerHealthController] is Healthy the
|
||||
* repository hits `/api/search` and returns the server's three-facet
|
||||
* paged response. When health is Offline or ServerDown it falls back to
|
||||
* Room LIKE queries against `cached_*` so the user can still find
|
||||
* something to play from what's already on the device; the outcome's
|
||||
* [SearchOutcome.localOnly] flag lets the screen draw an "offline
|
||||
* results" hint instead of pretending the server answered.
|
||||
*/
|
||||
@Singleton
|
||||
class SearchRepository @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
private val serverHealth: ServerHealthController,
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val albumDao: CachedAlbumDao,
|
||||
private val artistDao: CachedArtistDao,
|
||||
) {
|
||||
private val api: SearchApi = retrofit.create()
|
||||
|
||||
suspend fun search(query: String): SearchResponseRef {
|
||||
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
|
||||
ServerHealth.Healthy -> SearchOutcome(remoteSearch(query), localOnly = false)
|
||||
ServerHealth.Offline, ServerHealth.ServerDown ->
|
||||
SearchOutcome(localSearch(query), localOnly = true)
|
||||
}
|
||||
|
||||
private suspend fun remoteSearch(query: String): SearchResponseRef {
|
||||
val wire = api.search(query)
|
||||
return SearchResponseRef(
|
||||
artists = wire.artists.items.map { it.toDomain() },
|
||||
@@ -26,4 +48,21 @@ class SearchRepository @Inject constructor(
|
||||
tracks = wire.tracks.items.map { it.toDomain() },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun localSearch(query: String): SearchResponseRef = SearchResponseRef(
|
||||
artists = artistDao.searchByName(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
|
||||
albums = albumDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
|
||||
tracks = trackDao.searchByTitle(query, LOCAL_SEARCH_LIMIT).map { it.toDomain() },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the search response with the signal of whether the result came
|
||||
* from the server or from the local cached entities. The screen renders
|
||||
* the same SearchResponseRef either way; the flag drives the offline
|
||||
* banner copy.
|
||||
*/
|
||||
data class SearchOutcome(
|
||||
val response: SearchResponseRef,
|
||||
val localOnly: Boolean,
|
||||
)
|
||||
|
||||
@@ -158,17 +158,28 @@ private fun ResultsPane(
|
||||
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
|
||||
is SearchResultsState.Loaded -> {
|
||||
if (state.response.isEmpty) {
|
||||
CenteredHint("No matches for that query.")
|
||||
} else {
|
||||
ResultsList(
|
||||
response = state.response,
|
||||
playingTrackId = playingTrackId,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onTrackPlay = onTrackPlay,
|
||||
onNavigateToAlbum = onNavigateToAlbum,
|
||||
onNavigateToArtist = onNavigateToArtist,
|
||||
CenteredHint(
|
||||
if (state.localOnly) {
|
||||
"No matches in your on-device library."
|
||||
} else {
|
||||
"No matches for that query."
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (state.localOnly) {
|
||||
OfflineResultsHint()
|
||||
}
|
||||
ResultsList(
|
||||
response = state.response,
|
||||
playingTrackId = playingTrackId,
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onTrackPlay = onTrackPlay,
|
||||
onNavigateToAlbum = onNavigateToAlbum,
|
||||
onNavigateToArtist = onNavigateToArtist,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,6 +319,18 @@ private fun SectionHeader(label: String, count: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineResultsHint() {
|
||||
Text(
|
||||
text = "Showing on-device matches only — the server is unreachable.",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredHint(text: String) {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||
|
||||
@@ -26,7 +26,10 @@ sealed interface SearchResultsState {
|
||||
/** Empty query — screen shows "type to search" hint. */
|
||||
data object Idle : SearchResultsState
|
||||
data object Loading : SearchResultsState
|
||||
data class Loaded(val response: SearchResponseRef) : SearchResultsState
|
||||
data class Loaded(
|
||||
val response: SearchResponseRef,
|
||||
val localOnly: Boolean = false,
|
||||
) : SearchResultsState
|
||||
data class Error(val message: String) : SearchResultsState
|
||||
}
|
||||
|
||||
@@ -98,8 +101,15 @@ class SearchViewModel @Inject constructor(
|
||||
private suspend fun runSearch(q: String) {
|
||||
internal.update { it.copy(results = SearchResultsState.Loading) }
|
||||
try {
|
||||
val response = repository.search(q)
|
||||
internal.update { it.copy(results = SearchResultsState.Loaded(response)) }
|
||||
val outcome = repository.search(q)
|
||||
internal.update {
|
||||
it.copy(
|
||||
results = SearchResultsState.Loaded(
|
||||
response = outcome.response,
|
||||
localOnly = outcome.localOnly,
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
|
||||
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
|
||||
import com.fabledsword.minstrel.player.ui.MiniPlayer
|
||||
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
|
||||
@@ -48,6 +49,7 @@ fun ShellScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
|
||||
playbackErrorViewModel: PlaybackErrorViewModel = hiltViewModel(),
|
||||
offlineWriteHintViewModel: OfflineWriteHintViewModel = hiltViewModel(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -61,6 +63,11 @@ fun ShellScaffold(
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
offlineWriteHintViewModel.messages.collect { msg ->
|
||||
snackbarHostState.showSnackbar(msg)
|
||||
}
|
||||
}
|
||||
// Consume the status-bar inset once here so the banner stack sits
|
||||
// below the status bar (mirrors Flutter's SafeArea(bottom:false)).
|
||||
// statusBarsPadding consumes the inset for descendants, so the in-
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -12,8 +13,14 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
|
||||
private const val OFFLINE_UNAVAILABLE_ALPHA = 0.4f
|
||||
private const val OFFLINE_TAP_MESSAGE = "Not downloaded — connect to play"
|
||||
|
||||
/**
|
||||
* Shared track-list row. Replaces the 5 per-screen `TrackRow`s — every
|
||||
@@ -30,6 +37,14 @@ import androidx.compose.ui.unit.dp
|
||||
* for applying its own alpha if it should match (the row doesn't
|
||||
* cascade because the trailing slot's content is the caller's, not
|
||||
* ours).
|
||||
*
|
||||
* Reads [LocalServerHealth] + [LocalCachedTrackIds] and intercepts taps
|
||||
* on tracks that aren't downloaded when the server is unreachable —
|
||||
* fires a Toast instead of attempting playback. The text dims so the
|
||||
* user can see at a glance which rows in a long list will work offline.
|
||||
* The trailing slot stays interactive so the kebab / like / playlist-
|
||||
* add affordances can still queue mutations for offline replay (Phase
|
||||
* 5 of #618 gates those at the action level).
|
||||
*/
|
||||
@Composable
|
||||
fun TrackRow(
|
||||
@@ -45,15 +60,28 @@ fun TrackRow(
|
||||
leading: @Composable () -> Unit = {},
|
||||
trailing: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy &&
|
||||
trackId !in LocalCachedTrackIds.current
|
||||
val titleColor = if (nowPlaying) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
val effectiveAlpha = if (offlineUnavailable) {
|
||||
minOf(contentAlpha, OFFLINE_UNAVAILABLE_ALPHA)
|
||||
} else {
|
||||
contentAlpha
|
||||
}
|
||||
val effectiveOnClick: () -> Unit = if (offlineUnavailable) {
|
||||
{ Toast.makeText(context, OFFLINE_TAP_MESSAGE, Toast.LENGTH_SHORT).show() }
|
||||
} else {
|
||||
onClick
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.clickable(enabled = enabled, onClick = effectiveOnClick)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = horizontalArrangement,
|
||||
@@ -63,7 +91,7 @@ fun TrackRow(
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = titleColor.copy(alpha = contentAlpha),
|
||||
color = titleColor.copy(alpha = effectiveAlpha),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -71,7 +99,7 @@ fun TrackRow(
|
||||
Text(
|
||||
text = artist,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = effectiveAlpha),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
+46
-1
@@ -10,11 +10,25 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Retrofit
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
|
||||
|
||||
// Hysteresis on the reachable signal. Flipping internalReachable to false on
|
||||
// the very first /healthz failure produced two false positives:
|
||||
// 1. App startup — AuthStore.baseUrl loads from Room asynchronously, so the
|
||||
// first runOnce() can fire against AuthStore.DEFAULT_BASE_URL
|
||||
// ("http://localhost:8080") before the real server URL has hydrated.
|
||||
// 2. Deployments whose reverse proxy routes only /api/* to the Go server —
|
||||
// /healthz never reaches the handler, so the user sees a permanent
|
||||
// "Server unreachable" banner even though all real /api/* calls succeed.
|
||||
// Requiring 3 consecutive failures (~15 min at the 5-min poll cadence) ensures
|
||||
// the banner only fires on sustained, real unreachability — and a single
|
||||
// success at any point resets the counter so transient hiccups self-clear.
|
||||
private const val REACHABILITY_FAILURE_THRESHOLD = 3
|
||||
|
||||
/**
|
||||
* Result of the most recent /healthz version-compatibility check.
|
||||
* `Skipped` means the server didn't include `min_client_version`
|
||||
@@ -41,6 +55,18 @@ class VersionCheckController @Inject constructor(
|
||||
private val internal = MutableStateFlow(VersionResult.SKIPPED)
|
||||
val result: StateFlow<VersionResult> = internal.asStateFlow()
|
||||
|
||||
// Whether the most recent /healthz poll reached the server. Separate from
|
||||
// VersionResult because "unreachable" and "version mismatch" drive
|
||||
// different UX (offline banner vs version-too-old banner). Optimistic
|
||||
// initial value -- the first poll fires within seconds of app launch and
|
||||
// we don't want a "server down" flash before we've actually tried.
|
||||
// Consumed by ServerHealthController to compose with ConnectivityObserver
|
||||
// for the tri-state offline / server-down / healthy signal.
|
||||
private val internalReachable = MutableStateFlow(true)
|
||||
val reachable: StateFlow<Boolean> = internalReachable.asStateFlow()
|
||||
|
||||
private var consecutiveFailures = 0
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
@@ -56,7 +82,26 @@ class VersionCheckController @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun runOnce() {
|
||||
val response = runCatching { api.check() }.getOrNull() ?: return
|
||||
val outcome = runCatching { api.check() }
|
||||
val response = outcome.getOrNull()
|
||||
if (response == null) {
|
||||
consecutiveFailures++
|
||||
if (consecutiveFailures >= REACHABILITY_FAILURE_THRESHOLD &&
|
||||
internalReachable.value
|
||||
) {
|
||||
Timber.w(
|
||||
"/healthz unreachable for %d consecutive polls — flipping reachable=false",
|
||||
consecutiveFailures,
|
||||
)
|
||||
internalReachable.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!internalReachable.value) {
|
||||
Timber.i("/healthz recovered after %d failures", consecutiveFailures)
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
internalReachable.value = true
|
||||
val min = response.minClientVersion
|
||||
internal.value = when {
|
||||
min.isEmpty() -> VersionResult.SKIPPED
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RemotePlayerStateTest {
|
||||
|
||||
@Test
|
||||
fun `starts in idle state`() {
|
||||
val state = RemotePlayerState()
|
||||
assertFalse(state.isPlaying)
|
||||
assertEquals(0L, state.positionMs)
|
||||
assertEquals(0L, state.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyPositionInfo updates position, duration, and trackNumber`() {
|
||||
val state = RemotePlayerState()
|
||||
state.applyPositionInfo(
|
||||
positionMs = 65_000L, durationMs = 210_000L, trackUri = "x", trackNumber = 3,
|
||||
)
|
||||
assertEquals(65_000L, state.positionMs)
|
||||
assertEquals(210_000L, state.durationMs)
|
||||
assertEquals("x", state.currentTrackUri)
|
||||
assertEquals(3, state.trackNumber)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyTransportPlaying flips isPlaying true`() {
|
||||
val state = RemotePlayerState()
|
||||
state.applyTransportPlaying()
|
||||
assertTrue(state.isPlaying)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyTransportPaused flips isPlaying false`() {
|
||||
val state = RemotePlayerState().apply { applyTransportPlaying() }
|
||||
state.applyTransportPaused()
|
||||
assertFalse(state.isPlaying)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applyError resets to idle and records error`() {
|
||||
val state = RemotePlayerState().apply { applyTransportPlaying() }
|
||||
val ex = RuntimeException("disconnected")
|
||||
state.applyError(ex)
|
||||
assertFalse(state.isPlaying)
|
||||
assertEquals(ex, state.lastError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recordPollFailure trips after threshold`() {
|
||||
val state = RemotePlayerState()
|
||||
// Threshold is 30 -- tolerate ~30s of screen-off WiFi sleep before
|
||||
// declaring the remote dropped. Pre-threshold calls all return false.
|
||||
repeat(DROP_THRESHOLD - 1) {
|
||||
assertFalse(state.recordPollFailure())
|
||||
}
|
||||
assertTrue(state.recordPollFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recordPollSuccess clears the failure counter`() {
|
||||
val state = RemotePlayerState()
|
||||
repeat(DROP_THRESHOLD - 1) { state.recordPollFailure() }
|
||||
state.recordPollSuccess()
|
||||
assertFalse(state.recordPollFailure())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DROP_THRESHOLD = 30
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
class AVTransportClientTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: AVTransportClient
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
|
||||
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seek sends Target in HH MM SS format`() = runTest {
|
||||
server.enqueue(emptyResponse("Seek"))
|
||||
client.seek(positionMs = 65_000L)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<Target>0:01:05</Target>")) { "body was $body" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getPositionInfo parses Track, RelTime and TrackDuration`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:GetPositionInfoResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<Track>3</Track>
|
||||
<TrackDuration>0:03:30</TrackDuration>
|
||||
<TrackURI>http://x/y.mp3</TrackURI>
|
||||
<RelTime>0:01:05</RelTime>
|
||||
</u:GetPositionInfoResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent(),
|
||||
),
|
||||
)
|
||||
val info = client.getPositionInfo()
|
||||
assertEquals(3, info.track)
|
||||
assertEquals(65_000L, info.relTimeMs)
|
||||
assertEquals(210_000L, info.trackDurationMs)
|
||||
assertEquals("http://x/y.mp3", info.trackUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTransportInfo maps PAUSED_PLAYBACK to PAUSED`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:GetTransportInfoResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<CurrentTransportState>PAUSED_PLAYBACK</CurrentTransportState>
|
||||
<CurrentTransportStatus>OK</CurrentTransportStatus>
|
||||
<CurrentSpeed>1</CurrentSpeed>
|
||||
</u:GetTransportInfoResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent(),
|
||||
),
|
||||
)
|
||||
val info = client.getTransportInfo()
|
||||
assertEquals(TransportState.PAUSED, info.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTransportInfo maps unknown state to UNKNOWN`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:GetTransportInfoResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||
<CurrentTransportState>BUFFERING_PLAYBACK</CurrentTransportState>
|
||||
<CurrentTransportStatus>OK</CurrentTransportStatus>
|
||||
<CurrentSpeed>1</CurrentSpeed>
|
||||
</u:GetTransportInfoResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent(),
|
||||
),
|
||||
)
|
||||
val info = client.getTransportInfo()
|
||||
assertEquals(TransportState.UNKNOWN, info.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeAllTracksFromQueue sends correct SOAP action`() = runTest {
|
||||
server.enqueue(emptyResponse("RemoveAllTracksFromQueue"))
|
||||
client.removeAllTracksFromQueue()
|
||||
val request = server.takeRequest()
|
||||
assertTrue(request.getHeader("SOAPACTION").orEmpty().contains("RemoveAllTracksFromQueue")) {
|
||||
"SOAPACTION header missing action: ${request.getHeader("SOAPACTION")}"
|
||||
}
|
||||
val body = request.body.readUtf8()
|
||||
assertTrue(body.contains("RemoveAllTracksFromQueue")) { "body: $body" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addURIToQueue sends EnqueuedURI and DIDL-Lite metadata`() = runTest {
|
||||
server.enqueue(emptyResponse("AddURIToQueue"))
|
||||
client.addURIToQueue(
|
||||
uri = "http://x/y.mp3",
|
||||
mime = "audio/mpeg",
|
||||
title = "Song",
|
||||
enqueuedURIPosition = 2,
|
||||
)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<EnqueuedURI>http://x/y.mp3</EnqueuedURI>")) {
|
||||
"body missing EnqueuedURI: $body"
|
||||
}
|
||||
val positionTag = "<DesiredFirstTrackNumberEnqueued>2</DesiredFirstTrackNumberEnqueued>"
|
||||
assertTrue(body.contains(positionTag)) { "body missing position: $body" }
|
||||
assertTrue(body.contains("<dc:title>Song</dc:title>")) {
|
||||
"title missing in DIDL: $body"
|
||||
}
|
||||
assertTrue(body.contains("audio/mpeg")) { "mime missing: $body" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next sends Next SOAP action`() = runTest {
|
||||
server.enqueue(emptyResponse("Next"))
|
||||
client.next()
|
||||
val request = server.takeRequest()
|
||||
assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Next\"")) {
|
||||
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `previous sends Previous SOAP action`() = runTest {
|
||||
server.enqueue(emptyResponse("Previous"))
|
||||
client.previous()
|
||||
val request = server.takeRequest()
|
||||
assertTrue(request.getHeader("SOAPACTION").orEmpty().endsWith("#Previous\"")) {
|
||||
"SOAPACTION: ${request.getHeader("SOAPACTION")}"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seekToTrack sends TRACK_NR unit with 1-based target`() = runTest {
|
||||
server.enqueue(emptyResponse("Seek"))
|
||||
client.seekToTrack(trackNumber = 4)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<Unit>TRACK_NR</Unit>")) { "body: $body" }
|
||||
assertTrue(body.contains("<Target>4</Target>")) { "body: $body" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setAVTransportURIWithMetadata sends plain https URI for music track`() = runTest {
|
||||
server.enqueue(emptyResponse("SetAVTransportURI"))
|
||||
client.setAVTransportURIWithMetadata(
|
||||
uri = "https://example.com/track.mp3",
|
||||
mime = "audio/mpeg",
|
||||
title = "Song",
|
||||
)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<CurrentURI>https://example.com/track.mp3</CurrentURI>")) {
|
||||
"CurrentURI should be sent as plain https: $body"
|
||||
}
|
||||
assertFalse(body.contains("x-rincon-mp3radio")) {
|
||||
"x-rincon-mp3radio scheme should not appear: $body"
|
||||
}
|
||||
}
|
||||
|
||||
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:${action}Response xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class BareUdnTest {
|
||||
|
||||
@Test
|
||||
fun `strips uuid prefix`() {
|
||||
assertEquals("RINCON_ABC", "uuid:RINCON_ABC".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strips MR suffix`() {
|
||||
assertEquals("RINCON_ABC", "RINCON_ABC_MR".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strips MS suffix`() {
|
||||
assertEquals("RINCON_ABC", "RINCON_ABC_MS".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strips both uuid prefix and MR suffix`() {
|
||||
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MR".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `strips both uuid prefix and MS suffix`() {
|
||||
assertEquals("RINCON_ABC", "uuid:RINCON_ABC_MS".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `leaves already bare UDN unchanged`() {
|
||||
assertEquals("RINCON_ABC", "RINCON_ABC".bareUdn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MR suffix comparison crosses MediaRenderer vs ZGT response`() {
|
||||
val routeId = "uuid:RINCON_5CAAFD794B6401400_MR"
|
||||
val zgtMemberUdn = "RINCON_5CAAFD794B6401400"
|
||||
assertEquals(routeId.bareUdn(), zgtMemberUdn.bareUdn())
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
/**
|
||||
* Unit tests for the UPnP device-description pull-parser.
|
||||
*
|
||||
* Android's stock `XmlPullParserFactory.newInstance()` resolves to a
|
||||
* real impl on-device but resolves to the android.jar stub class on
|
||||
* JVM unit tests. kxml2 is added as a testImplementation dep so
|
||||
* `XmlPullParserFactory.newInstance()` finds it via service-provider
|
||||
* lookup on the test classpath; the tests run unconditionally.
|
||||
*/
|
||||
class DeviceDescriptionTest {
|
||||
|
||||
private val base = "http://192.168.1.50:1400/xml/device_description.xml".toHttpUrl()
|
||||
|
||||
@Test
|
||||
fun `parses Sonos-shaped description`() {
|
||||
val xml = """
|
||||
<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<deviceType>urn:schemas-upnp-org:device:MediaRenderer:1</deviceType>
|
||||
<friendlyName>Living Room</friendlyName>
|
||||
<manufacturer>Sonos, Inc.</manufacturer>
|
||||
<modelName>Sonos One</modelName>
|
||||
<UDN>uuid:RINCON_ABC</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<controlURL>/MediaRenderer/AVTransport/Control</controlURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:RenderingControl:1</serviceType>
|
||||
<controlURL>/MediaRenderer/RenderingControl/Control</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
""".trimIndent()
|
||||
|
||||
val desc = DeviceDescription.parse(xml, base)
|
||||
assertNotNull(desc)
|
||||
assertEquals("Living Room", desc.friendlyName)
|
||||
assertEquals("Sonos, Inc.", desc.manufacturer)
|
||||
assertEquals("Sonos One", desc.modelName)
|
||||
assertEquals("uuid:RINCON_ABC", desc.udn)
|
||||
assertEquals(
|
||||
"http://192.168.1.50:1400/MediaRenderer/AVTransport/Control",
|
||||
desc.avTransportControlUrl.toString(),
|
||||
)
|
||||
assertEquals(
|
||||
"http://192.168.1.50:1400/MediaRenderer/RenderingControl/Control",
|
||||
desc.renderingControlUrl?.toString(),
|
||||
)
|
||||
assertNull(desc.zoneGroupTopologyControlUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drops device with no AVTransport service`() {
|
||||
val xml = """
|
||||
<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<friendlyName>Stub TV</friendlyName>
|
||||
<UDN>uuid:STUB</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
|
||||
<controlURL>/cm</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
""".trimIndent()
|
||||
|
||||
assertNull(DeviceDescription.parse(xml, base))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses ZoneGroupTopology control URL when present`() {
|
||||
val xml = """
|
||||
<?xml version="1.0"?>
|
||||
<root xmlns="urn:schemas-upnp-org:device-1-0">
|
||||
<device>
|
||||
<UDN>uuid:RINCON_XYZ</UDN>
|
||||
<friendlyName>Office</friendlyName>
|
||||
<manufacturer>Sonos, Inc.</manufacturer>
|
||||
<modelName>Sonos One</modelName>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<controlURL>/MediaRenderer/AVTransport/Control</controlURL>
|
||||
</service>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:ZoneGroupTopology:1</serviceType>
|
||||
<controlURL>/ZoneGroupTopology/Control</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
""".trimIndent()
|
||||
val desc = DeviceDescription.parse(xml, base)
|
||||
assertNotNull(desc)
|
||||
assertEquals(
|
||||
"http://192.168.1.50:1400/ZoneGroupTopology/Control",
|
||||
desc.zoneGroupTopologyControlUrl?.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles missing optional fields`() {
|
||||
val xml = """
|
||||
<?xml version="1.0"?>
|
||||
<root>
|
||||
<device>
|
||||
<UDN>uuid:MIN</UDN>
|
||||
<serviceList>
|
||||
<service>
|
||||
<serviceType>urn:schemas-upnp-org:service:AVTransport:1</serviceType>
|
||||
<controlURL>/avt</controlURL>
|
||||
</service>
|
||||
</serviceList>
|
||||
</device>
|
||||
</root>
|
||||
""".trimIndent()
|
||||
|
||||
val desc = DeviceDescription.parse(xml, base)
|
||||
assertNotNull(desc)
|
||||
assertEquals("", desc.friendlyName)
|
||||
assertEquals("", desc.manufacturer)
|
||||
assertEquals("", desc.modelName)
|
||||
assertNull(desc.renderingControlUrl)
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
|
||||
class RenderingControlClientTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: RenderingControlClient
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
client = RenderingControlClient(SoapClient(OkHttpClient()), server.url("/RC"))
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun tearDown() { server.shutdown() }
|
||||
|
||||
@Test
|
||||
fun `getVolume parses CurrentVolume`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:GetVolumeResponse xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1">
|
||||
<CurrentVolume>42</CurrentVolume>
|
||||
</u:GetVolumeResponse>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent()))
|
||||
assertEquals(42, client.getVolume())
|
||||
val request = server.takeRequest()
|
||||
val body = request.body.readUtf8()
|
||||
assertTrue(body.contains("<Channel>Master</Channel>")) { body }
|
||||
assertEquals(
|
||||
"\"urn:schemas-upnp-org:service:RenderingControl:1#GetVolume\"",
|
||||
request.getHeader("SOAPACTION"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setVolume clamps and sends DesiredVolume`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:SetVolumeResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent()))
|
||||
client.setVolume(150)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<DesiredVolume>100</DesiredVolume>")) { body }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setVolume clamps below VOLUME_MIN to zero`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(
|
||||
"""<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<s:Body>
|
||||
<u:SetVolumeResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:RenderingControl:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>""".trimIndent()))
|
||||
client.setVolume(-5)
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("<DesiredVolume>0</DesiredVolume>")) { body }
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Unit tests for SoapClient. Uses MockWebServer to capture the
|
||||
* outgoing SOAP envelope + verify the SOAPACTION header shape, and
|
||||
* to drive the `<s:Fault>` response path.
|
||||
*
|
||||
* kxml2 is on the test classpath (Task 4), so
|
||||
* `XmlPullParserFactory.newInstance()` resolves on the JVM.
|
||||
*/
|
||||
class SoapClientTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var client: SoapClient
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
server = MockWebServer().apply { start() }
|
||||
client = SoapClient(OkHttpClient())
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SetAVTransportURI envelope sent with correct SOAPACTION header`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setBody(setUriResponseBody())
|
||||
.setHeader("Content-Type", "text/xml"),
|
||||
)
|
||||
|
||||
client.call(
|
||||
controlUrl = server.url("/avt/Control"),
|
||||
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
|
||||
action = "SetAVTransportURI",
|
||||
args = mapOf(
|
||||
"InstanceID" to "0",
|
||||
"CurrentURI" to "http://server/api/tracks/x/stream?token=t&exp=1",
|
||||
"CurrentURIMetaData" to "",
|
||||
),
|
||||
)
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
assertEquals(
|
||||
"\"urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI\"",
|
||||
recorded.getHeader("SOAPACTION"),
|
||||
)
|
||||
val body = recorded.body.readUtf8()
|
||||
assertTrue(
|
||||
body.contains("<u:SetAVTransportURI"),
|
||||
"envelope missing <u:SetAVTransportURI: $body",
|
||||
)
|
||||
assertTrue(
|
||||
body.contains(
|
||||
"<CurrentURI>http://server/api/tracks/x/stream?token=t&exp=1</CurrentURI>",
|
||||
),
|
||||
"envelope missing escaped CurrentURI: $body",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `XML special chars in args are escaped`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setBody(setUriResponseBody())
|
||||
.setHeader("Content-Type", "text/xml"),
|
||||
)
|
||||
|
||||
client.call(
|
||||
controlUrl = server.url("/avt/Control"),
|
||||
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
|
||||
action = "SetAVTransportURI",
|
||||
args = mapOf("CurrentURI" to "a&b<c>\"d'e"),
|
||||
)
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
val body = recorded.body.readUtf8()
|
||||
assertTrue(
|
||||
body.contains("a&b<c>"d'e"),
|
||||
"expected all five XML escapes in body, got: $body",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SOAP fault becomes SoapFaultException`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(500)
|
||||
.setBody(faultResponseBody())
|
||||
.setHeader("Content-Type", "text/xml"),
|
||||
)
|
||||
|
||||
val ex = assertFailsWith<SoapFaultException> {
|
||||
client.call(
|
||||
controlUrl = server.url("/avt/Control"),
|
||||
serviceType = "urn:schemas-upnp-org:service:AVTransport:1",
|
||||
action = "Play",
|
||||
args = mapOf("InstanceID" to "0", "Speed" to "1"),
|
||||
)
|
||||
}
|
||||
assertEquals("402", ex.code)
|
||||
}
|
||||
|
||||
private fun setUriResponseBody(): String = """
|
||||
<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<u:SetAVTransportURIResponse
|
||||
xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"/>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""".trimIndent()
|
||||
|
||||
private fun faultResponseBody(): String = """
|
||||
<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||
<s:Body>
|
||||
<s:Fault>
|
||||
<faultcode>s:Client</faultcode>
|
||||
<faultstring>UPnPError</faultstring>
|
||||
<detail>
|
||||
<UPnPError xmlns="urn:schemas-upnp-org:control-1-0">
|
||||
<errorCode>402</errorCode>
|
||||
<errorDescription>Invalid Args</errorDescription>
|
||||
</UPnPError>
|
||||
</detail>
|
||||
</s:Fault>
|
||||
</s:Body>
|
||||
</s:Envelope>
|
||||
""".trimIndent()
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.fabledsword.minstrel.player.output.upnp.sonos
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class SonosTopologyTest {
|
||||
|
||||
@Test
|
||||
fun `single zone group with one member`() {
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
|
||||
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
|
||||
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals(1, groups.size)
|
||||
val g = groups.first()
|
||||
assertEquals("RINCON_A", g.coordinatorUdn)
|
||||
assertEquals("Kitchen", g.name)
|
||||
assertEquals(1, g.members.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stereo pair collapses to one group with two members`() {
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_L" ID="RINCON_L:2">
|
||||
<ZoneGroupMember UUID="RINCON_L" ZoneName="Living Room"
|
||||
Location="http://192.168.1.11:1400/xml/device_description.xml"
|
||||
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
|
||||
<ZoneGroupMember UUID="RINCON_R" ZoneName="Living Room"
|
||||
Location="http://192.168.1.12:1400/xml/device_description.xml"
|
||||
ChannelMapSet="RINCON_L:LF,LF;RINCON_R:RF,RF"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals(1, groups.size)
|
||||
val g = groups.first()
|
||||
assertEquals("RINCON_L", g.coordinatorUdn)
|
||||
assertEquals("Living Room", g.name)
|
||||
assertEquals(2, g.members.size)
|
||||
assertNotNull(g.members[0].channelMapSet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multi-speaker group lists coordinator name`() {
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_X" ID="RINCON_X:3">
|
||||
<ZoneGroupMember UUID="RINCON_X" ZoneName="Office"/>
|
||||
<ZoneGroupMember UUID="RINCON_Y" ZoneName="Bedroom"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals("Office", groups.first().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed xml returns empty list`() {
|
||||
assertEquals(emptyList(), SonosTopology.parse("<garbage"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses inline non-escaped ZoneGroupState document`() {
|
||||
// Some Sonos firmware sends the topology as nested elements with
|
||||
// no escaping. After SoapClient.readUntilEndTag rebuilds a flat
|
||||
// string, parse should still find the groups.
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
|
||||
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
|
||||
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals(1, groups.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses escaped ZoneGroupState wrapped in entities`() {
|
||||
val xml = """
|
||||
<ZoneGroupState>
|
||||
<ZoneGroups>
|
||||
<ZoneGroup Coordinator="RINCON_A" ID="RINCON_A:1">
|
||||
<ZoneGroupMember UUID="RINCON_A" ZoneName="Kitchen"
|
||||
Location="http://192.168.1.10:1400/xml/device_description.xml"/>
|
||||
</ZoneGroup>
|
||||
</ZoneGroups>
|
||||
</ZoneGroupState>
|
||||
""".trimIndent()
|
||||
val groups = SonosTopology.parse(xml)
|
||||
assertEquals(1, groups.size)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ kotlinx-coroutines = "1.9.0"
|
||||
kotlinx-datetime = "0.6.1"
|
||||
kotlinx-serialization-converter = "1.0.0"
|
||||
media3 = "1.10.1"
|
||||
mediarouter = "1.7.0"
|
||||
coil = "3.0.0-rc02"
|
||||
palette = "1.0.0"
|
||||
timber = "5.0.1"
|
||||
@@ -33,6 +34,7 @@ mockk = "1.13.13"
|
||||
compose-test = "1.7.5"
|
||||
ktlint-gradle = "12.1.1"
|
||||
detekt = "2.0.0-alpha.3"
|
||||
kxml2 = "2.3.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.13.1" }
|
||||
@@ -71,6 +73,7 @@ media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref =
|
||||
media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" }
|
||||
media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }
|
||||
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
||||
mediarouter = { module = "androidx.mediarouter:mediarouter", version.ref = "mediarouter" }
|
||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
|
||||
androidx-palette = { module = "androidx.palette:palette-ktx", version.ref = "palette" }
|
||||
@@ -87,6 +90,7 @@ turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
|
||||
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
|
||||
compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||
kxml2 = { module = "net.sf.kxml:kxml2", version.ref = "kxml2" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -245,6 +245,7 @@ func run() error {
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
|
||||
srv.Bus = bus
|
||||
srv.PlaylistScheduler = playlistScheduler
|
||||
srv.StreamSecret = cfg.StreamSecret
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Server.Address,
|
||||
Handler: srv.Router(),
|
||||
|
||||
+33
-2
@@ -28,7 +28,7 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
@@ -47,6 +47,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
mailer: sender,
|
||||
eventbus: bus,
|
||||
playlistScheduler: playlistScheduler,
|
||||
streamSecret: streamSecret,
|
||||
}
|
||||
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
@@ -54,6 +55,21 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
api.Post("/auth/register", h.handleRegister)
|
||||
api.Post("/auth/forgot-password", h.handleForgotPassword)
|
||||
api.Post("/auth/reset-password", h.handleResetPassword)
|
||||
|
||||
// Stream lives outside authed.Group so it can accept EITHER a
|
||||
// session (resolved by the OptionalUser middleware) OR a signed
|
||||
// query token (UPnP / Sonos path; see streamAuthOk). The
|
||||
// middleware attaches user to context when a valid cookie /
|
||||
// bearer is present but does NOT 401 on absence; the handler's
|
||||
// own streamAuthOk performs the actual auth check. See the
|
||||
// design at
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream", h.handleGetStream)
|
||||
// Extension-bearing alias so Sonos's URL probe can identify the
|
||||
// audio format from the path. The {ext} param is consumed by chi
|
||||
// and ignored by the handler (which keys off {id}). See task #610.
|
||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
||||
|
||||
api.Group(func(authed chi.Router) {
|
||||
authed.Use(auth.RequireUser(pool))
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
@@ -77,7 +93,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
||||
authed.Get("/library/sync", h.handleLibrarySync)
|
||||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||
authed.Get("/tracks/{id}/stream", h.handleGetStream)
|
||||
// /tracks/{id}/stream is mounted above with OptionalUser so
|
||||
// it can accept either a session or a signed token.
|
||||
authed.Get("/search", h.handleSearch)
|
||||
authed.Get("/radio", h.handleRadio)
|
||||
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
||||
@@ -85,6 +102,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/home/index", h.handleGetHomeIndex)
|
||||
authed.Post("/events", h.handleEvents)
|
||||
authed.Get("/events/stream", h.handleEventsStream)
|
||||
// UPnP / Sonos cast slice: issue a short-lived HMAC stream URL
|
||||
// the speaker can fetch without the user's session. See the
|
||||
// design at
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
authed.Post("/cast/stream-token", h.handleCastStreamToken)
|
||||
authed.Post("/likes/tracks/{id}", h.handleLikeTrack)
|
||||
authed.Delete("/likes/tracks/{id}", h.handleUnlikeTrack)
|
||||
authed.Post("/likes/albums/{id}", h.handleLikeAlbum)
|
||||
@@ -205,4 +227,13 @@ type handlers struct {
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
playlistScheduler *playlists.Scheduler
|
||||
// streamSecret is the HMAC key used by SignStreamToken /
|
||||
// VerifyStreamToken to authenticate the UPnP-speaker stream path
|
||||
// (see internal/api/stream_token.go and the design at
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md).
|
||||
// nil in slice 1; slice 2 wires the env-var-with-app_preferences-
|
||||
// fallback loader. A nil secret leaves the cookie path intact and
|
||||
// makes the token path unreachable (HMAC of empty key won't match
|
||||
// anything a client mints), which is the desired slice-1 default.
|
||||
streamSecret []byte
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
const (
|
||||
castTokenMinExpSeconds = 60
|
||||
castTokenMaxExpSeconds = 86400 // 24h
|
||||
castTokenDefaultExp = 21600 // 6h
|
||||
)
|
||||
|
||||
type castTokenRequest struct {
|
||||
TrackID string `json:"trackId"`
|
||||
ExpSeconds int `json:"expSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type castTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
Exp int64 `json:"exp"`
|
||||
URL string `json:"url"`
|
||||
// MIME and Title let the client build proper DIDL-Lite metadata for
|
||||
// SetAVTransportURI. Sonos rejects empty DIDL with vendor error 1023;
|
||||
// passing back the track's MIME + title here lets the client populate
|
||||
// `<res protocolInfo>` and `<dc:title>` without a follow-up round trip.
|
||||
MIME string `json:"mime"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL.
|
||||
// Wraps the canonical audioContentType lookup in media.go and overrides
|
||||
// the unknown-format fallback to audio/mpeg, because Sonos rejects
|
||||
// DIDL-Lite with protocolInfo=application/octet-stream (the browser
|
||||
// fallback) -- most Sonos firmware probes the URL anyway and recovers
|
||||
// from a small MIME mismatch.
|
||||
func mimeForFormat(format string) string {
|
||||
mime := audioContentType(format)
|
||||
if mime == "application/octet-stream" {
|
||||
return "audio/mpeg"
|
||||
}
|
||||
return mime
|
||||
}
|
||||
|
||||
// extForFormat maps the tracks.file_format column to a path-safe file
|
||||
// extension. Sonos firmware gates duration probing on the URL path
|
||||
// extension (Content-Type header alone is insufficient) -- without a
|
||||
// recognizable extension, Sonos reports TrackDuration=0 and seeks
|
||||
// trigger auto-advance because every position past 0 looks past-the-
|
||||
// end. Defaults to "mp3" for unknown formats. See task #610.
|
||||
func extForFormat(format string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "mp3", "mpeg":
|
||||
return "mp3"
|
||||
case "flac":
|
||||
return "flac"
|
||||
case "aac":
|
||||
return "aac"
|
||||
case "m4a", "mp4":
|
||||
return "m4a"
|
||||
case "ogg", "vorbis":
|
||||
return "ogg"
|
||||
case "opus":
|
||||
return "opus"
|
||||
case "wav", "wave":
|
||||
return "wav"
|
||||
default:
|
||||
return "mp3"
|
||||
}
|
||||
}
|
||||
|
||||
// handleCastStreamToken issues a short-lived HMAC stream token for the
|
||||
// given trackId. Authenticated via the standard session cookie / bearer.
|
||||
//
|
||||
// The returned URL is a fully-formed stream URL (token + exp embedded
|
||||
// as query params) that the client passes verbatim to a UPnP / Sonos
|
||||
// device's AVTransport.SetAVTransportURI call — those devices cannot
|
||||
// carry the user's session, so the signed query string is the only way
|
||||
// they can fetch the bytes.
|
||||
//
|
||||
// expSeconds is clamped to [60, 86400]; default 21600 (6h) — long enough
|
||||
// to play through any typical track without re-minting mid-playback.
|
||||
//
|
||||
// Part of the output-picker UPnP slice. See
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var req castTokenRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
trackUUID, ok := parseUUID(req.TrackID)
|
||||
if !ok || !trackUUID.Valid {
|
||||
writeErr(w, apierror.BadRequest("invalid_track_id", "trackId must be a UUID"))
|
||||
return
|
||||
}
|
||||
// Track lookup for the DIDL-Lite metadata the client builds for
|
||||
// SetAVTransportURI. A missing track is a 404 — there's nothing to
|
||||
// cast in that case.
|
||||
track, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackUUID)
|
||||
if err != nil {
|
||||
writeErr(w, apierror.NotFound("track"))
|
||||
return
|
||||
}
|
||||
expSec := clampExpSeconds(req.ExpSeconds)
|
||||
exp := time.Now().Add(time.Duration(expSec) * time.Second).Unix()
|
||||
token := SignStreamToken(h.streamSecret, req.TrackID, exp)
|
||||
|
||||
// Behind a TLS-terminating reverse proxy, r.TLS is nil even though
|
||||
// the public-facing URL is https://. UPnP devices (Sonos especially)
|
||||
// reject SetAVTransportURI with error 714 (IllegalMimeType) when
|
||||
// they hit an http:// URL that immediately 301s to https:// — the
|
||||
// MIME probe fails to find an audio body. Honor X-Forwarded-Proto +
|
||||
// X-Forwarded-Host first so the URL we hand to the speaker reaches
|
||||
// it on the same scheme/host the client used. Falls back to
|
||||
// r.TLS-based detection for direct (no-proxy) deployments.
|
||||
scheme := "http"
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||
scheme = proto
|
||||
} else if r.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
host := r.Host
|
||||
if h := r.Header.Get("X-Forwarded-Host"); h != "" {
|
||||
host = h
|
||||
}
|
||||
// Include the file extension in the path so Sonos's URL probe sees a
|
||||
// recognizable audio file. Without it, Sonos reports TrackDuration=0
|
||||
// and seeks past 0s land "after the end" -> early track-skip.
|
||||
url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
|
||||
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
|
||||
|
||||
writeJSON(w, http.StatusOK, castTokenResponse{
|
||||
Token: token,
|
||||
Exp: exp,
|
||||
URL: url,
|
||||
MIME: mimeForFormat(track.FileFormat),
|
||||
Title: track.Title,
|
||||
})
|
||||
}
|
||||
|
||||
// clampExpSeconds applies the [60, 86400] window with a 6h default for
|
||||
// non-positive inputs. Extracted so it doesn't bloat the handler's
|
||||
// detekt-equivalent line count and so the test can exercise edges directly.
|
||||
func clampExpSeconds(v int) int {
|
||||
if v <= 0 {
|
||||
return castTokenDefaultExp
|
||||
}
|
||||
if v < castTokenMinExpSeconds {
|
||||
return castTokenMinExpSeconds
|
||||
}
|
||||
if v > castTokenMaxExpSeconds {
|
||||
return castTokenMaxExpSeconds
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// nonExistentTrackUUID is used by tests that exercise paths which don't
|
||||
// require the track to actually exist (auth/UUID-shape rejection).
|
||||
const nonExistentTrackUUID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
func TestCastStreamToken_HappyPath(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", false)
|
||||
artist := seedArtist(t, pool, "Artist")
|
||||
album := seedAlbum(t, pool, artist.ID, "Album", 0)
|
||||
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
|
||||
trackID := uuidToString(track.ID)
|
||||
h.streamSecret = []byte("cast-token-test-secret")
|
||||
|
||||
body, err := json.Marshal(castTokenRequest{
|
||||
TrackID: trackID,
|
||||
ExpSeconds: 3600,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, user)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.handleCastStreamToken(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp castTokenResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Token == "" || resp.Exp == 0 || resp.URL == "" {
|
||||
t.Fatalf("empty fields in response: %+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.URL, "token="+resp.Token) {
|
||||
t.Fatalf("URL missing token query: %s", resp.URL)
|
||||
}
|
||||
if !strings.Contains(resp.URL, "/api/tracks/"+trackID+"/stream") {
|
||||
t.Fatalf("URL missing stream path: %s", resp.URL)
|
||||
}
|
||||
// Stream URL must carry a file extension so Sonos's URL probe can
|
||||
// identify the audio format (see task #610). Track seeded above is
|
||||
// .flac via seedTrack's default file_format.
|
||||
if !strings.Contains(resp.URL, "/stream.flac?") {
|
||||
t.Fatalf("URL missing file-extension segment: %s", resp.URL)
|
||||
}
|
||||
if !VerifyStreamToken(h.streamSecret, trackID, resp.Exp, resp.Token) {
|
||||
t.Fatal("returned token does not verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCastStreamToken_RejectsBadUUID(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", false)
|
||||
h.streamSecret = []byte("cast-token-test-secret")
|
||||
|
||||
body, err := json.Marshal(castTokenRequest{TrackID: "not-a-uuid"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, user)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.handleCastStreamToken(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCastStreamToken_RejectsUnauthenticated(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
h.streamSecret = []byte("cast-token-test-secret")
|
||||
|
||||
body, err := json.Marshal(castTokenRequest{TrackID: nonExistentTrackUUID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// NO withUser — handler must reject via requireUser.
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.handleCastStreamToken(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCastStreamToken_ClampsExpSeconds(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "alice", "hunter2", false)
|
||||
artist := seedArtist(t, pool, "Artist")
|
||||
album := seedAlbum(t, pool, artist.ID, "Album", 0)
|
||||
track := seedTrack(t, pool, album.ID, artist.ID, "Song", 1, 180_000)
|
||||
trackID := uuidToString(track.ID)
|
||||
h.streamSecret = []byte("cast-token-test-secret")
|
||||
|
||||
// Request 1 second (below min 60), expect clamp to 60s.
|
||||
body, err := json.Marshal(castTokenRequest{
|
||||
TrackID: trackID,
|
||||
ExpSeconds: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
before := time.Now().Unix()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/cast/stream-token", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, user)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.handleCastStreamToken(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp castTokenResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
// Allow a small jitter window around the clamp target (60s).
|
||||
delta := resp.Exp - before
|
||||
if delta < 55 || delta > 70 {
|
||||
t.Fatalf("expected ~60s expiry after clamp, got delta=%ds", delta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampExpSeconds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in int
|
||||
want int
|
||||
}{
|
||||
{"zero defaults to 6h", 0, castTokenDefaultExp},
|
||||
{"negative defaults to 6h", -1, castTokenDefaultExp},
|
||||
{"below min clamps up", 30, castTokenMinExpSeconds},
|
||||
{"exact min passes through", castTokenMinExpSeconds, castTokenMinExpSeconds},
|
||||
{"mid-range passes through", 3600, 3600},
|
||||
{"exact max passes through", castTokenMaxExpSeconds, castTokenMaxExpSeconds},
|
||||
{"above max clamps down", castTokenMaxExpSeconds + 1, castTokenMaxExpSeconds},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := clampExpSeconds(tc.in); got != tc.want {
|
||||
t.Fatalf("clampExpSeconds(%d) = %d, want %d", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,15 @@ func streamURL(trackID pgtype.UUID) string {
|
||||
return "/api/tracks/" + uuidToString(trackID) + "/stream"
|
||||
}
|
||||
|
||||
// streamURLWithExt returns the extension-bearing stream URL used by UPnP
|
||||
// cast tokens. Sonos's URL probe gates duration detection on a recognizable
|
||||
// audio file extension; the bare `/stream` shape reports TrackDuration=0
|
||||
// and breaks seek/auto-advance. The bare /stream route stays mounted as an
|
||||
// alias for legacy / web / Subsonic clients. See task #610.
|
||||
func streamURLWithExt(trackID pgtype.UUID, ext string) string {
|
||||
return streamURL(trackID) + "." + ext
|
||||
}
|
||||
|
||||
// artistRefFrom projects a dbq.Artist into an ArtistRef without cover.
|
||||
// albumCount must be pre-computed by the caller. Used by code paths that
|
||||
// don't have a representative-album lookup at hand (artist detail, search,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
@@ -21,7 +22,15 @@ import (
|
||||
// newLibraryRouter builds a test-only chi router with the library handlers
|
||||
// mounted at their path-style routes. Tests hit this router rather than
|
||||
// calling handler methods directly so chi URL params populate correctly.
|
||||
// RequireUser is NOT applied — library handlers don't read user context.
|
||||
// RequireUser is NOT applied to most routes — library handlers don't read
|
||||
// user context.
|
||||
//
|
||||
// The stream route is wrapped with a synthetic-user middleware because
|
||||
// handleGetStream now enforces its own auth check (streamAuthOk) after the
|
||||
// UPnP slice moved it out of the authed.Group. Real traffic carries either
|
||||
// a session cookie (resolved by auth.OptionalUser) or a signed query
|
||||
// token; tests get a fake user-in-context so the cookie path of
|
||||
// streamAuthOk succeeds without seeding a real session row.
|
||||
func newLibraryRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/artists", h.handleListArtists)
|
||||
@@ -29,11 +38,23 @@ func newLibraryRouter(h *handlers) chi.Router {
|
||||
r.Get("/api/albums/{id}", h.handleGetAlbum)
|
||||
r.Get("/api/albums/{id}/cover", h.handleGetCover)
|
||||
r.Get("/api/tracks/{id}", h.handleGetTrack)
|
||||
r.Get("/api/tracks/{id}/stream", h.handleGetStream)
|
||||
r.With(injectFakeUserForTest).Get("/api/tracks/{id}/stream", h.handleGetStream)
|
||||
r.Get("/api/search", h.handleSearch)
|
||||
return r
|
||||
}
|
||||
|
||||
// injectFakeUserForTest attaches an empty dbq.User to request context via
|
||||
// auth.UserCtxKeyForTest(), letting handleGetStream's streamAuthOk succeed
|
||||
// on the session path without the test having to seed a real session row.
|
||||
// Production traffic uses auth.OptionalUser instead; this is the test-only
|
||||
// equivalent that bypasses the DB lookup.
|
||||
func injectFakeUserForTest(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.WithValue(r.Context(), auth.UserCtxKeyForTest(), dbq.User{})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleGetTrack_HappyPath(t *testing.T) {
|
||||
h, pool := testHandlers(t)
|
||||
truncateLibrary(t, pool)
|
||||
@@ -444,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
30*time.Minute, 0.5, 30000)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
+71
-25
@@ -11,53 +11,53 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// resolveAlbumCoverPath returns the filesystem path to the album's cover art.
|
||||
// It prefers an explicit cover_art_path (set by the scanner in a future
|
||||
// milestone) and falls back to a sidecar next to the first track in the
|
||||
// album's directory. "" means no art was found.
|
||||
// resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a
|
||||
// local alias so the call sites in this file read naturally.
|
||||
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
|
||||
if album.CoverArtPath != nil && *album.CoverArtPath != "" {
|
||||
if _, err := os.Stat(*album.CoverArtPath); err == nil {
|
||||
return *album.CoverArtPath
|
||||
}
|
||||
}
|
||||
tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID})
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return coverart.FindSidecar(filepath.Dir(tracks[0].FilePath))
|
||||
return coverart.ResolveAlbumPath(ctx, q, album)
|
||||
}
|
||||
|
||||
// audioContentType maps the short file_format recorded on tracks (mp3, flac,
|
||||
// ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header.
|
||||
// Unknown formats fall back to octet-stream so the browser downloads them
|
||||
// rather than attempting to decode.
|
||||
// This is the canonical table; both the browser stream endpoint and the
|
||||
// UPnP cast token URL builder consult it. Unknown formats fall back to
|
||||
// octet-stream so the browser downloads them rather than attempting to
|
||||
// decode -- cast_token.go applies its own audio/mpeg fallback for Sonos.
|
||||
//
|
||||
// Aliases (mpeg/vorbis/wave) cover historical / alternate format spellings
|
||||
// that have shown up in track rows. The trim+lowercase normalization makes
|
||||
// the lookup permissive to whatever a scanner happened to write.
|
||||
//
|
||||
// Divergences from internal/subsonic/types.go's contentTypeForFormat are
|
||||
// intentional: opus→audio/ogg (library .opus files are Ogg-encapsulated, so
|
||||
// this matches real library contents), aac→audio/aac (raw AAC is ADTS, not
|
||||
// MP4, so audio/mp4 would mislead codec sniffers), and there is no "oga" case
|
||||
// (we don't record that format). Don't "fix" these to match subsonic.
|
||||
// intentional: opus/vorbis→audio/ogg (library .opus / .ogg files are
|
||||
// Ogg-encapsulated, so this matches real library contents), aac→audio/aac
|
||||
// (raw AAC is ADTS, not MP4, so audio/mp4 would mislead codec sniffers),
|
||||
// and there is no "oga" case (we don't record that format). Subsonic is a
|
||||
// frozen client contract -- don't "fix" these to match it.
|
||||
func audioContentType(format string) string {
|
||||
switch strings.ToLower(format) {
|
||||
case "mp3":
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "mp3", "mpeg":
|
||||
return "audio/mpeg"
|
||||
case "flac":
|
||||
return "audio/flac"
|
||||
case "ogg", "opus":
|
||||
case "ogg", "opus", "vorbis":
|
||||
return "audio/ogg"
|
||||
case "m4a":
|
||||
case "m4a", "mp4":
|
||||
return "audio/mp4"
|
||||
case "aac":
|
||||
return "audio/aac"
|
||||
case "wav":
|
||||
case "wav", "wave":
|
||||
return "audio/wav"
|
||||
}
|
||||
return "application/octet-stream"
|
||||
@@ -121,10 +121,56 @@ func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
||||
}
|
||||
|
||||
// streamAuthOk returns true if the request is authorized to fetch the
|
||||
// stream — either via the standard session path (cookie OR bearer token
|
||||
// resolved to a user-in-context by auth.OptionalUser, the middleware
|
||||
// the route is wrapped with in api.go) OR via a valid short-lived HMAC
|
||||
// token in the ?token=&exp= query (UPnP / Sonos path, new for the
|
||||
// output-picker UPnP slice).
|
||||
//
|
||||
// The token path lets network speakers fetch the stream URL without
|
||||
// carrying the user's session cookie — they cannot. See the design at
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
||||
//
|
||||
// When h.streamSecret is nil (slice-1 default, until slice 2 wires the
|
||||
// loader), the token path always rejects because the HMAC of an empty
|
||||
// key won't match any token a client could mint. The session path keeps
|
||||
// working.
|
||||
func (h *handlers) streamAuthOk(r *http.Request, trackID string) bool {
|
||||
if _, ok := auth.UserFromContext(r.Context()); ok {
|
||||
return true
|
||||
}
|
||||
tok := r.URL.Query().Get("token")
|
||||
expStr := r.URL.Query().Get("exp")
|
||||
if tok == "" || expStr == "" {
|
||||
return false
|
||||
}
|
||||
exp, err := strconv.ParseInt(expStr, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return VerifyStreamToken(h.streamSecret, trackID, exp, tok)
|
||||
}
|
||||
|
||||
// handleGetStream implements GET /api/tracks/{id}/stream. Opens the file on
|
||||
// disk and delegates byte-serving to http.ServeContent, which handles Range,
|
||||
// If-Modified-Since, and ETag based on the file's mod time.
|
||||
//
|
||||
// The route lives outside the authed.Group so this handler can accept
|
||||
// EITHER a session-resolved user (attached by auth.OptionalUser, the
|
||||
// permissive middleware the route is wrapped with) OR a signed token
|
||||
// (UPnP slice). streamAuthOk gates both paths.
|
||||
func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
|
||||
// Auth check before the DB lookup: a 404 ahead of the auth check
|
||||
// would let unauth callers probe which track IDs exist via the
|
||||
// 404/401 response differential. streamAuthOk is keyed on the
|
||||
// path's id directly (the HMAC token is signed over the same id
|
||||
// string, so we don't need the resolved row yet).
|
||||
rawID := chi.URLParam(r, "id")
|
||||
if !h.streamAuthOk(r, rawID) {
|
||||
writeErr(w, apierror.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track")
|
||||
if apiErr != nil {
|
||||
writeErr(w, apiErr)
|
||||
|
||||
@@ -476,8 +476,8 @@ func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView {
|
||||
if t.TrackID != nil {
|
||||
s := uuidToString(*t.TrackID)
|
||||
v.TrackID = &s
|
||||
streamURL := "/api/tracks/" + s + "/stream"
|
||||
v.StreamURL = &streamURL
|
||||
url := streamURL(*t.TrackID)
|
||||
v.StreamURL = &url
|
||||
}
|
||||
if t.AlbumID != nil {
|
||||
s := uuidToString(*t.AlbumID)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SignStreamToken returns the HMAC-SHA256 hex of "<trackID>|<exp>" keyed
|
||||
// by secret. The token is constant-time-comparable via VerifyStreamToken
|
||||
// so it's safe to expose the verification function in handlers.
|
||||
//
|
||||
// trackID is the canonical track UUID. exp is the Unix time after which
|
||||
// the token is invalid; the handler checks exp before serving.
|
||||
//
|
||||
// The secret comes from MINSTREL_STREAM_SECRET (or its auto-generated +
|
||||
// persisted fallback in app_preferences). The loader lands in slice 2
|
||||
// (POST /api/cast/stream-token); this file is the primitive both the
|
||||
// loader and the stream handler share.
|
||||
func SignStreamToken(secret []byte, trackID string, exp int64) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
// hash.Hash.Write is documented as never returning an error.
|
||||
_, _ = fmt.Fprintf(mac, "%s|%d", trackID, exp)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// VerifyStreamToken returns true iff token is a valid HMAC for
|
||||
// (trackID, exp) AND exp has not yet passed. Wall-clock comparison —
|
||||
// callers must trust the server's clock. Uses hmac.Equal for
|
||||
// constant-time compare so a timing oracle can't bias token guessing.
|
||||
func VerifyStreamToken(secret []byte, trackID string, exp int64, token string) bool {
|
||||
if time.Now().Unix() > exp {
|
||||
return false
|
||||
}
|
||||
expected := SignStreamToken(secret, trackID, exp)
|
||||
return hmac.Equal([]byte(expected), []byte(token))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignStreamToken_RoundTrip(t *testing.T) {
|
||||
secret := []byte("test-secret-not-real")
|
||||
trackID := "abc-123"
|
||||
exp := time.Now().Unix() + 3600
|
||||
|
||||
token := SignStreamToken(secret, trackID, exp)
|
||||
if token == "" {
|
||||
t.Fatal("got empty token")
|
||||
}
|
||||
if !VerifyStreamToken(secret, trackID, exp, token) {
|
||||
t.Fatal("round-trip verify failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyStreamToken_TamperedTokenRejected(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
trackID := "abc-123"
|
||||
exp := time.Now().Unix() + 3600
|
||||
|
||||
token := SignStreamToken(secret, trackID, exp)
|
||||
// Flip a hex digit anywhere in the middle.
|
||||
mid := len(token) / 2
|
||||
tampered := token[:mid] + flipHexDigit(token[mid:mid+1]) + token[mid+1:]
|
||||
|
||||
if VerifyStreamToken(secret, trackID, exp, tampered) {
|
||||
t.Fatal("verify accepted tampered token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyStreamToken_ExpiredRejected(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
trackID := "abc-123"
|
||||
exp := time.Now().Unix() - 1 // already expired
|
||||
|
||||
token := SignStreamToken(secret, trackID, exp)
|
||||
if VerifyStreamToken(secret, trackID, exp, token) {
|
||||
t.Fatal("verify accepted expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyStreamToken_WrongTrackIDRejected(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
exp := time.Now().Unix() + 3600
|
||||
|
||||
token := SignStreamToken(secret, "track-a", exp)
|
||||
if VerifyStreamToken(secret, "track-b", exp, token) {
|
||||
t.Fatal("verify accepted token for different track")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyStreamToken_WrongSecretRejected(t *testing.T) {
|
||||
trackID := "abc-123"
|
||||
exp := time.Now().Unix() + 3600
|
||||
|
||||
token := SignStreamToken([]byte("secret-a"), trackID, exp)
|
||||
if VerifyStreamToken([]byte("secret-b"), trackID, exp, token) {
|
||||
t.Fatal("verify accepted token signed with different secret")
|
||||
}
|
||||
}
|
||||
|
||||
func flipHexDigit(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
switch s[0] {
|
||||
case '0':
|
||||
return "1"
|
||||
default:
|
||||
return "0"
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,52 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
|
||||
// middleware. Do not use this outside _test.go files.
|
||||
func UserCtxKeyForTest() any { return userCtxKey }
|
||||
|
||||
// OptionalUser is RequireUser's permissive sibling: it resolves the caller
|
||||
// from the session cookie or bearer header and attaches the user to context
|
||||
// when present + valid, but does NOT 401 on absence. The downstream handler
|
||||
// runs unconditionally and is responsible for its own auth check via
|
||||
// UserFromContext (or its own bespoke path — see /api/tracks/{id}/stream's
|
||||
// streamAuthOk, which accepts EITHER a user-in-context OR a signed query
|
||||
// token for UPnP / Sonos speakers that don't carry the user's cookie).
|
||||
//
|
||||
// Invalid tokens (stale session row, deleted user) silently drop through
|
||||
// without attaching the user. The handler treats "no user in context" as
|
||||
// "not authenticated" the same way it treats a missing cookie.
|
||||
//
|
||||
// Database lookup failures fall through too — a transient DB blip should not
|
||||
// 5xx a stream request that may have a perfectly valid signed token. The
|
||||
// error is logged so the operator can correlate.
|
||||
func OptionalUser(pool *pgxpool.Pool, logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := sessionTokenFromRequest(r)
|
||||
if token == "" || pool == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
q := dbq.New(pool)
|
||||
sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
|
||||
logger.Warn("api: optional session lookup failed", "err", err)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
user, err := q.GetUserByID(r.Context(), sess.UserID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
|
||||
logger.Warn("api: optional user lookup failed", "err", err)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sessionTokenFromRequest(r *http.Request) string {
|
||||
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -19,6 +23,14 @@ type Config struct {
|
||||
Events EventsConfig `yaml:"events"`
|
||||
Recommendation RecommendationConfig `yaml:"recommendation"`
|
||||
Branding BrandingConfig `yaml:"branding"`
|
||||
// StreamSecret is the base64-decoded HMAC key used to sign UPnP /
|
||||
// Sonos stream URLs (see internal/api/stream_token.go and
|
||||
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md).
|
||||
// Sourced from MINSTREL_STREAM_SECRET (base64-encoded), with a
|
||||
// per-machine fallback persisted at <Storage.DataDir>/stream_secret.
|
||||
// Not serialized to YAML — operator-machine-scoped runtime state, not
|
||||
// a user-facing setting. Never log the contents.
|
||||
StreamSecret []byte `yaml:"-"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -137,9 +149,83 @@ func Load(path string) (Config, error) {
|
||||
}
|
||||
}
|
||||
applyEnv(&cfg)
|
||||
if err := resolveStreamSecret(&cfg); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// streamSecretBytes is the raw HMAC-key length; 64 bytes gives 512 bits
|
||||
// of entropy, more than enough for HMAC-SHA256, and lands at a 86-char
|
||||
// base64-url-no-padding string that fits cleanly in a single .env line.
|
||||
const streamSecretBytes = 64
|
||||
|
||||
// streamSecretFile is the basename for the per-machine persisted fallback
|
||||
// under <Storage.DataDir>. Kept as a constant so tests assert against the
|
||||
// exact path and operators can find it during backup planning.
|
||||
const streamSecretFile = "stream_secret"
|
||||
|
||||
// resolveStreamSecret populates cfg.StreamSecret using, in order:
|
||||
// 1. MINSTREL_STREAM_SECRET env var (operator-supplied, base64-url
|
||||
// encoded — accepts either padded or raw form).
|
||||
// 2. <Storage.DataDir>/stream_secret if present (auto-generated on a
|
||||
// previous boot; survives restarts so signed URLs stay valid).
|
||||
// 3. Auto-generate streamSecretBytes random bytes, persist them to that
|
||||
// file at 0600, then use them.
|
||||
//
|
||||
// Logs (only) when an auto-generation happens so the operator can spot
|
||||
// it during first-boot. Never logs the contents.
|
||||
//
|
||||
// The loader runs before slog is initialized in cmd/minstrel/main.go;
|
||||
// log.Printf is the project's pre-logger convention.
|
||||
func resolveStreamSecret(cfg *Config) error {
|
||||
if raw := strings.TrimSpace(os.Getenv("MINSTREL_STREAM_SECRET")); raw != "" {
|
||||
decoded, err := decodeBase64Secret(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode MINSTREL_STREAM_SECRET: %w", err)
|
||||
}
|
||||
cfg.StreamSecret = decoded
|
||||
return nil
|
||||
}
|
||||
if cfg.Storage.DataDir == "" {
|
||||
return fmt.Errorf("stream secret: empty storage.data_dir; " +
|
||||
"set MINSTREL_STREAM_SECRET or storage.data_dir")
|
||||
}
|
||||
path := filepath.Join(cfg.Storage.DataDir, streamSecretFile)
|
||||
if buf, err := os.ReadFile(path); err == nil && len(buf) > 0 {
|
||||
decoded, err := decodeBase64Secret(strings.TrimSpace(string(buf)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode %s: %w", path, err)
|
||||
}
|
||||
cfg.StreamSecret = decoded
|
||||
return nil
|
||||
}
|
||||
raw := make([]byte, streamSecretBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return fmt.Errorf("auto-gen stream secret: %w", err)
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(raw)
|
||||
if err := os.MkdirAll(cfg.Storage.DataDir, 0o755); err != nil {
|
||||
return fmt.Errorf("ensure data_dir for stream secret: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(encoded), 0o600); err != nil {
|
||||
return fmt.Errorf("persist stream secret: %w", err)
|
||||
}
|
||||
log.Printf("config: auto-generated MINSTREL_STREAM_SECRET (persisted to %s)", path)
|
||||
cfg.StreamSecret = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeBase64Secret accepts base64-url with OR without padding; the
|
||||
// stream-secret bytes are opaque so callers shouldn't have to know which
|
||||
// encoder produced their string. Returns an error on any other shape.
|
||||
func decodeBase64Secret(s string) ([]byte, error) {
|
||||
if buf, err := base64.RawURLEncoding.DecodeString(s); err == nil {
|
||||
return buf, nil
|
||||
}
|
||||
return base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func applyEnv(cfg *Config) {
|
||||
if v, ok := os.LookupEnv("MINSTREL_SERVER_ADDRESS"); ok {
|
||||
cfg.Server.Address = v
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// stubStreamSecret keeps existing tests focused on the fields they
|
||||
// assert against by short-circuiting the auto-generation path (which
|
||||
// would otherwise write ./data/stream_secret in the test workspace).
|
||||
// New tests that exercise the resolver directly do NOT use this.
|
||||
func stubStreamSecret(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("MINSTREL_STREAM_SECRET",
|
||||
base64.RawURLEncoding.EncodeToString([]byte("test-stream-secret-stub-1234567890")))
|
||||
}
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Server.Address != ":4533" {
|
||||
@@ -17,6 +29,7 @@ func TestDefault(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadYAML(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
content := []byte(`server:
|
||||
@@ -46,6 +59,7 @@ log:
|
||||
}
|
||||
|
||||
func TestLoadMissingFileReturnsDefaults(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
cfg, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
@@ -56,6 +70,7 @@ func TestLoadMissingFileReturnsDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEnvOverrides(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
t.Setenv("MINSTREL_SERVER_ADDRESS", ":8080")
|
||||
t.Setenv("MINSTREL_DATABASE_URL", "postgres://env")
|
||||
t.Setenv("MINSTREL_LOG_LEVEL", "WARN")
|
||||
@@ -80,6 +95,7 @@ func TestEnvOverrides(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLibraryEnvOverrides(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true")
|
||||
|
||||
@@ -102,6 +118,7 @@ func TestLibraryEnvOverrides(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSubsonicEnvOverride(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
t.Setenv("MINSTREL_SUBSONIC_ALLOW_PLAINTEXT_PASSWORD", "true")
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
@@ -113,6 +130,7 @@ func TestSubsonicEnvOverride(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLibraryYAMLLoads(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
content := []byte(`library:
|
||||
@@ -147,6 +165,7 @@ func TestBrandingDefaults(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBrandingYAMLOverride(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
content := []byte(`branding:
|
||||
@@ -168,7 +187,81 @@ func TestBrandingYAMLOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSecret_EnvOverride(t *testing.T) {
|
||||
want := []byte("hello-stream-secret-from-env-32b")
|
||||
t.Setenv("MINSTREL_STREAM_SECRET", base64.RawURLEncoding.EncodeToString(want))
|
||||
t.Setenv("MINSTREL_STORAGE_DATA_DIR", t.TempDir())
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if string(cfg.StreamSecret) != string(want) {
|
||||
t.Fatalf("StreamSecret mismatch: got %q want %q", cfg.StreamSecret, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSecret_AutoGenPersistsToDataDir(t *testing.T) {
|
||||
_ = os.Unsetenv("MINSTREL_STREAM_SECRET")
|
||||
dataDir := t.TempDir()
|
||||
t.Setenv("MINSTREL_STORAGE_DATA_DIR", dataDir)
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(cfg.StreamSecret) != streamSecretBytes {
|
||||
t.Fatalf("StreamSecret len = %d, want %d", len(cfg.StreamSecret), streamSecretBytes)
|
||||
}
|
||||
path := filepath.Join(dataDir, streamSecretFile)
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("expected persisted secret at %s: %v", path, err)
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(string(buf)))
|
||||
if err != nil {
|
||||
t.Fatalf("persisted secret is not raw-url-base64: %v", err)
|
||||
}
|
||||
if string(decoded) != string(cfg.StreamSecret) {
|
||||
t.Fatal("persisted file does not match returned secret")
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
// 0600 — never let other users read the HMAC key.
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Fatalf("perm = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSecret_LoadsPersistedOnSecondBoot(t *testing.T) {
|
||||
_ = os.Unsetenv("MINSTREL_STREAM_SECRET")
|
||||
dataDir := t.TempDir()
|
||||
t.Setenv("MINSTREL_STORAGE_DATA_DIR", dataDir)
|
||||
|
||||
first, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load #1: %v", err)
|
||||
}
|
||||
second, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load #2: %v", err)
|
||||
}
|
||||
if string(first.StreamSecret) != string(second.StreamSecret) {
|
||||
t.Fatal("second Load got a different secret — file fallback didn't fire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamSecret_RejectsMalformedEnvValue(t *testing.T) {
|
||||
t.Setenv("MINSTREL_STREAM_SECRET", "not!base64!!!")
|
||||
t.Setenv("MINSTREL_STORAGE_DATA_DIR", t.TempDir())
|
||||
if _, err := Load(""); err == nil {
|
||||
t.Fatal("expected error on malformed MINSTREL_STREAM_SECRET")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrandingEnvOverride(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
t.Setenv("MINSTREL_BRANDING_APP_NAME", "Office Music")
|
||||
t.Setenv("MINSTREL_BRANDING_DESCRIPTION", "Office tunes.")
|
||||
cfg, err := Load("")
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
package coverart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// SidecarNames is the lookup order for cover art living next to audio files.
|
||||
@@ -33,3 +36,24 @@ func FindSidecar(albumDir string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ResolveAlbumPath returns the on-disk path to an album's cover image,
|
||||
// preferring the explicit album.cover_art_path when set and the file
|
||||
// exists, falling back to a sidecar (cover.jpg / folder.jpg) next to the
|
||||
// first track in the album's directory. "" means no art was found.
|
||||
//
|
||||
// Shared by internal/api/media.go (browser endpoint) and
|
||||
// internal/subsonic/stream.go (Subsonic endpoint); they were byte-identical
|
||||
// duplicates before extraction.
|
||||
func ResolveAlbumPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
|
||||
if album.CoverArtPath != nil && *album.CoverArtPath != "" {
|
||||
if _, err := os.Stat(*album.CoverArtPath); err == nil {
|
||||
return *album.CoverArtPath
|
||||
}
|
||||
}
|
||||
tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID})
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return FindSidecar(filepath.Dir(tracks[0].FilePath))
|
||||
}
|
||||
|
||||
@@ -176,16 +176,43 @@ func fallbackGlyph() image.Image {
|
||||
return img
|
||||
}
|
||||
|
||||
// drawScaled copies src into dst.Rect, scaling with simple nearest-neighbor.
|
||||
// stdlib lacks high-quality scaling; nearest-neighbor is fine for a
|
||||
// 600x600 output where each cell is 300x300 — most album covers are
|
||||
// already 300-1500 pixels and the visual loss is minor.
|
||||
// drawScaled copies src into dst.Rect using a center-cropped "cover" fit
|
||||
// (the same model as BoxFit.cover / object-fit: cover in the clients).
|
||||
// Non-square sources are scaled so the *smaller* destination dimension is
|
||||
// fully filled and the larger axis is center-cropped, preserving aspect
|
||||
// ratio. Without this, banner-shaped or LP-shaped album art stretches in
|
||||
// the cell -- the album-coherent system playlists (new_for_you,
|
||||
// first_listens) make the stretching disproportionately visible because
|
||||
// fewer unique covers contribute, so each warped cell is a quarter of
|
||||
// the collage rather than diluted.
|
||||
//
|
||||
// Scaling itself stays nearest-neighbor -- stdlib lacks high-quality
|
||||
// scaling and dependency cost is unjustified for this 600x600 output.
|
||||
func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) {
|
||||
srcBounds := src.Bounds()
|
||||
srcW := srcBounds.Dx()
|
||||
srcH := srcBounds.Dy()
|
||||
if srcW <= 0 || srcH <= 0 {
|
||||
return
|
||||
}
|
||||
dstW := r.Dx()
|
||||
dstH := r.Dy()
|
||||
// Cover-fit: the side of src that maps to dst at the larger scale
|
||||
// fully fills its axis; the other axis is center-cropped.
|
||||
scaleX := float64(dstW) / float64(srcW)
|
||||
scaleY := float64(dstH) / float64(srcH)
|
||||
scale := scaleX
|
||||
if scaleY > scale {
|
||||
scale = scaleY
|
||||
}
|
||||
cropW := float64(dstW) / scale
|
||||
cropH := float64(dstH) / scale
|
||||
cropOffX := float64(srcBounds.Min.X) + (float64(srcW)-cropW)/2
|
||||
cropOffY := float64(srcBounds.Min.Y) + (float64(srcH)-cropH)/2
|
||||
for y := r.Min.Y; y < r.Max.Y; y++ {
|
||||
sy := int(cropOffY + float64(y-r.Min.Y)*cropH/float64(dstH))
|
||||
for x := r.Min.X; x < r.Max.X; x++ {
|
||||
sx := srcBounds.Min.X + (x-r.Min.X)*srcBounds.Dx()/r.Dx()
|
||||
sy := srcBounds.Min.Y + (y-r.Min.Y)*srcBounds.Dy()/r.Dy()
|
||||
sx := int(cropOffX + float64(x-r.Min.X)*cropW/float64(dstW))
|
||||
dst.Set(x, y, src.At(sx, sy))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,16 +278,23 @@ func RefreshableSystemKind(key string) bool {
|
||||
// here (plus its candidate query). Order is the materialize order;
|
||||
// it has no functional effect (atomic replace + per-playlist
|
||||
// collage are order-independent).
|
||||
var systemPlaylistRegistry = []systemPlaylistKind{
|
||||
{Key: "for_you", Singleton: true, Produce: produceForYou},
|
||||
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
|
||||
{Key: "discover", Singleton: true, Produce: produceDiscover},
|
||||
{Key: "deep_cuts", Singleton: true, Produce: produceDeepCuts},
|
||||
{Key: "rediscover", Singleton: true, Produce: produceRediscover},
|
||||
{Key: "new_for_you", Singleton: true, Produce: produceNewForYou},
|
||||
{Key: "on_this_day", Singleton: true, Produce: produceOnThisDay},
|
||||
{Key: "first_listens", Singleton: true, Produce: produceFirstListens},
|
||||
}
|
||||
var systemPlaylistRegistry = func() []systemPlaylistKind {
|
||||
out := []systemPlaylistKind{
|
||||
{Key: "for_you", Singleton: true, Produce: produceForYou},
|
||||
{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
|
||||
{Key: "discover", Singleton: true, Produce: produceDiscover},
|
||||
}
|
||||
// The five discovery mixes share one Produce closure factory keyed
|
||||
// by a per-mix spec; spec list + factory live in system_mixes.go.
|
||||
for _, spec := range discoveryMixSpecs {
|
||||
out = append(out, systemPlaylistKind{
|
||||
Key: spec.variant,
|
||||
Singleton: true,
|
||||
Produce: produceDiscoveryMix(spec),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// systemForYouSourceLimits is a deeper candidate pool than the radio
|
||||
// default. On a self-hosted library without ListenBrainz similarity
|
||||
|
||||
+221
-108
@@ -3,6 +3,7 @@ package playlists
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -10,29 +11,206 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Discovery mixes (#419-423). Each is one candidate query + a thin
|
||||
// producer registered in systemPlaylistRegistry. They follow the
|
||||
// Discover model: a SQL query returns ordered (id, album_id,
|
||||
// artist_id) rows; we optionally diversity-cap, truncate, and emit
|
||||
// rankedCandidate (Score unused — SQL gave the ranking). All are
|
||||
// singleton kinds, so the generic by-kind refresh/shuffle endpoints
|
||||
// and per-tile refresh affordance work with zero client changes.
|
||||
// Discovery mixes (#419-423). One generic producer + per-mix spec.
|
||||
// Replaces the five near-identical produceXxx functions that all
|
||||
// fetched ranked (id, album_id, artist_id) rows, diversified,
|
||||
// truncated, and emitted a single playlist.
|
||||
//
|
||||
// Day-keying is a per-mix property captured by `dailyRotate`:
|
||||
//
|
||||
// - DeepCuts / OnThisDay — SQL already day-keys via
|
||||
// ORDER BY md5(t.id::text || $2::text), so the Go producer keeps
|
||||
// SQL order. `dailyRotate: false`.
|
||||
//
|
||||
// - Rediscover / NewForYou / FirstListens — SQL accepts only $1
|
||||
// user_id and produces deterministic ordering. `dailyRotate:
|
||||
// true` applies a daily-deterministic rotate-left of the pool
|
||||
// BEFORE diversify+truncate so each day's top-100 surfaces a
|
||||
// different slice while contiguous-block ordering within each
|
||||
// slice is preserved (matters for FirstListens / NewForYou which
|
||||
// are album-coherent — rotation walks the album boundary cleanly
|
||||
// rather than scrambling within an album).
|
||||
//
|
||||
// Diversity is `true` for every mix: per-album <= 2 / per-artist <= 3.
|
||||
// On thin libraries where the cap would chop the pool below 100,
|
||||
// finishMix tops up from the uncapped raw pool so the mix still
|
||||
// ships a full-length playlist — see topUpFromRaw.
|
||||
|
||||
// discoveryMixLen caps each mix at the same depth as For-You /
|
||||
// Discover so shuffle-on-play has a varied pool within a day.
|
||||
const discoveryMixLen = 100
|
||||
|
||||
// finishMix caps (per-album<=2 / per-artist<=3) when diversify is
|
||||
// set, truncates to discoveryMixLen, and converts to the insert
|
||||
// type. Album-coherent mixes (New for you, First Listens) pass
|
||||
// diversify=false so whole albums survive.
|
||||
func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
|
||||
pool := rows
|
||||
if diversify {
|
||||
pool = capByAlbumAndArtist(pool)
|
||||
// discoveryMixSpec describes one discovery mix. The unified producer
|
||||
// reads the spec and runs a single code path for all variants.
|
||||
type discoveryMixSpec struct {
|
||||
name string
|
||||
variant string
|
||||
diversify bool
|
||||
|
||||
// dailyRotate, when true, applies a daily-deterministic offset
|
||||
// rotation to the candidate pool BEFORE diversify+truncate so the
|
||||
// top discoveryMixLen rotates day-over-day. Set on variants whose
|
||||
// SQL ORDER is invariant to dateStr (Rediscover, FirstListens).
|
||||
// Leave false when the SQL already day-keys (DeepCuts, OnThisDay)
|
||||
// or when day-over-day stability is the intended UX (NewForYou).
|
||||
dailyRotate bool
|
||||
|
||||
// fetch returns the raw ranked rows. dateStr is supplied for
|
||||
// queries that accept it (passed as the second positional arg
|
||||
// historically); queries that don't accept it ignore the param.
|
||||
fetch func(context.Context, *dbq.Queries, pgtype.UUID, string) ([]discoverTrack, error)
|
||||
}
|
||||
|
||||
// produceDiscoveryMix returns a systemPlaylistKind.Produce closure
|
||||
// bound to the given spec. Registered in systemPlaylistRegistry; see
|
||||
// the discoveryMixSpecs slice below for the concrete instances.
|
||||
func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
|
||||
return func(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, dateStr string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := spec.fetch(ctx, q, userID, dateStr)
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: "+spec.variant+" query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
pool := rows
|
||||
if spec.dailyRotate {
|
||||
pool = rotateForDay(pool, userID, dateStr)
|
||||
}
|
||||
return emit(spec.name, spec.variant, finishMix(pool, spec.diversify)), nil
|
||||
}
|
||||
if len(pool) > discoveryMixLen {
|
||||
pool = pool[:discoveryMixLen]
|
||||
}
|
||||
|
||||
// rotateForDay rotates pool left by a daily-deterministic offset so
|
||||
// each day's downstream truncate-to-N surfaces a different slice of
|
||||
// the pool while contiguous-block ordering inside the slice is
|
||||
// preserved. Empty / single-element pools pass through unchanged.
|
||||
func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack {
|
||||
n := len(pool)
|
||||
if n <= 1 {
|
||||
return pool
|
||||
}
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
offset := rng.Intn(n)
|
||||
rotated := make([]discoverTrack, 0, n)
|
||||
rotated = append(rotated, pool[offset:]...)
|
||||
rotated = append(rotated, pool[:offset]...)
|
||||
return rotated
|
||||
}
|
||||
|
||||
// discoveryMixSpecs is the concrete spec list used by the registry in
|
||||
// system.go. Adding a new mix = one entry here + the candidate query.
|
||||
//
|
||||
// Order has no functional effect (insert-time atomic replace is order
|
||||
// independent); listed in the same order as the historical registry.
|
||||
var discoveryMixSpecs = []discoveryMixSpec{
|
||||
{
|
||||
name: "Deep Cuts", variant: "deep_cuts",
|
||||
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
|
||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
|
||||
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
|
||||
UserID: uid, Column2: ds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Rediscover", variant: "rediscover",
|
||||
diversify: true, dailyRotate: true, // SQL has no date arg
|
||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
||||
rows, err := q.ListRediscoverTracks(ctx, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "New for you", variant: "new_for_you",
|
||||
diversify: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes
|
||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
||||
rows, err := q.ListNewForYouTracks(ctx, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "On this day", variant: "on_this_day",
|
||||
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
|
||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
|
||||
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
|
||||
UserID: uid, Column2: ds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "First listens", variant: "first_listens",
|
||||
diversify: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up
|
||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
||||
rows, err := q.ListFirstListensTracks(ctx, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// finishMix applies diversity caps (per-album <= 2 / per-artist <= 3)
|
||||
// when diversify is set, with a top-up fallback when caps strip the
|
||||
// pool below discoveryMixLen: the capped result is filled out with
|
||||
// non-capped tracks (preserving original SQL order) until the target
|
||||
// is hit or the raw pool runs out.
|
||||
//
|
||||
// The fallback matters on small / album-heavy libraries — the cap
|
||||
// can chop a 200-row pool down to 40, and we'd rather ship a partly-
|
||||
// diversified 100 than a strictly-diversified 40. On rich libraries
|
||||
// the cap yields >= 100 and the top-up path never runs.
|
||||
func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
|
||||
var pool []discoverTrack
|
||||
if diversify {
|
||||
capped := capByAlbumAndArtist(rows)
|
||||
if len(capped) >= discoveryMixLen {
|
||||
pool = capped[:discoveryMixLen]
|
||||
} else {
|
||||
pool = topUpFromRaw(capped, rows, discoveryMixLen)
|
||||
}
|
||||
} else {
|
||||
pool = rows
|
||||
if len(pool) > discoveryMixLen {
|
||||
pool = pool[:discoveryMixLen]
|
||||
}
|
||||
}
|
||||
if len(pool) == 0 {
|
||||
return nil
|
||||
@@ -44,6 +222,32 @@ func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
|
||||
return tracks
|
||||
}
|
||||
|
||||
// topUpFromRaw appends non-capped tracks from raw (in their original
|
||||
// order) onto capped, skipping any already present, until the result
|
||||
// reaches target or raw is exhausted. Preserves SQL ranking semantics
|
||||
// for the non-diverse fill so the topped-up tail still trends best-
|
||||
// first within each album.
|
||||
func topUpFromRaw(capped, raw []discoverTrack, target int) []discoverTrack {
|
||||
if len(capped) >= target {
|
||||
return capped[:target]
|
||||
}
|
||||
seen := make(map[pgtype.UUID]struct{}, len(capped))
|
||||
for _, t := range capped {
|
||||
seen[t.ID] = struct{}{}
|
||||
}
|
||||
pool := capped
|
||||
for _, t := range raw {
|
||||
if _, in := seen[t.ID]; in {
|
||||
continue
|
||||
}
|
||||
pool = append(pool, t)
|
||||
if len(pool) >= target {
|
||||
break
|
||||
}
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// emit wraps the finished track list in a single builtPlaylist (the
|
||||
// discovery mixes are all singletons). nil tracks → no playlist.
|
||||
func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist {
|
||||
@@ -52,94 +256,3 @@ func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist {
|
||||
}
|
||||
return []builtPlaylist{{Name: name, Variant: variant, Tracks: tracks}}
|
||||
}
|
||||
|
||||
func produceDeepCuts(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, dateStr string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
|
||||
UserID: userID, Column2: dateStr,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: deep-cuts query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
dt := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return emit("Deep Cuts", "deep_cuts", finishMix(dt, true)), nil
|
||||
}
|
||||
|
||||
func produceRediscover(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, _ string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := q.ListRediscoverTracks(ctx, userID)
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: rediscover query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
dt := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return emit("Rediscover", "rediscover", finishMix(dt, true)), nil
|
||||
}
|
||||
|
||||
func produceNewForYou(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, _ string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := q.ListNewForYouTracks(ctx, userID)
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: new-for-you query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
dt := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
// Album-coherent: no diversity cap so whole new albums survive.
|
||||
return emit("New for you", "new_for_you", finishMix(dt, false)), nil
|
||||
}
|
||||
|
||||
func produceOnThisDay(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, dateStr string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
|
||||
UserID: userID, Column2: dateStr,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: on-this-day query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
dt := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
return emit("On this day", "on_this_day", finishMix(dt, true)), nil
|
||||
}
|
||||
|
||||
func produceFirstListens(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, _ string, _ time.Time,
|
||||
) ([]builtPlaylist, error) {
|
||||
rows, err := q.ListFirstListensTracks(ctx, userID)
|
||||
if err != nil {
|
||||
logger.Warn("system playlist: first-listens query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
dt := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
}
|
||||
// Album-coherent (tiered by liked/played artist in SQL): no cap.
|
||||
return emit("First listens", "first_listens", finishMix(dt, false)), nil
|
||||
}
|
||||
|
||||
@@ -89,6 +89,12 @@ type Server struct {
|
||||
// PUT /api/me/timezone and POST /api/auth/register can call
|
||||
// Refresh synchronously.
|
||||
PlaylistScheduler *playlists.Scheduler
|
||||
// StreamSecret is the HMAC key used by /api/cast/stream-token to
|
||||
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
|
||||
// to verify them. Sourced from config.Config.StreamSecret. Tests that
|
||||
// leave it nil leave the cookie path intact and reject all signed
|
||||
// tokens (HMAC of empty key matches nothing a client could mint).
|
||||
StreamSecret []byte
|
||||
}
|
||||
|
||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server {
|
||||
@@ -138,7 +144,7 @@ func (s *Server) Router() http.Handler {
|
||||
if bus == nil {
|
||||
bus = eventbus.New()
|
||||
}
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler)
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
|
||||
// /api/admin/scan is the only admin route owned by the server package
|
||||
// (it needs the Scanner). Register it as a single inline-middleware
|
||||
// route — using r.Route("/api/admin", ...) here would create a second
|
||||
|
||||
@@ -130,21 +130,10 @@ func (m *mediaHandlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request
|
||||
WriteFail(w, r, ErrDataNotFound, "Cover art not found")
|
||||
}
|
||||
|
||||
// resolveAlbumCoverPath returns the filesystem path to the album's cover art,
|
||||
// preferring an explicit cover_art_path (set by the scanner in a future
|
||||
// milestone) and falling back to a sidecar image next to any track in the
|
||||
// album directory. "" means no art was found.
|
||||
// resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a
|
||||
// local alias so the call sites in this file read naturally.
|
||||
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
|
||||
if album.CoverArtPath != nil && *album.CoverArtPath != "" {
|
||||
if _, err := os.Stat(*album.CoverArtPath); err == nil {
|
||||
return *album.CoverArtPath
|
||||
}
|
||||
}
|
||||
tracks, err := q.ListTracksByAlbum(ctx, dbq.ListTracksByAlbumParams{AlbumID: album.ID})
|
||||
if err != nil || len(tracks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return coverart.FindSidecar(filepath.Dir(tracks[0].FilePath))
|
||||
return coverart.ResolveAlbumPath(ctx, q, album)
|
||||
}
|
||||
|
||||
func serveImage(w http.ResponseWriter, r *http.Request, path string) {
|
||||
|
||||
Reference in New Issue
Block a user