v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation #77

Merged
bvandeusen merged 29 commits from dev into main 2026-06-03 14:09:23 -04:00
2 changed files with 11 additions and 8 deletions
Showing only changes of commit 448c9f2e74 - Show all commits
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.create
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -171,10 +172,10 @@ class OutputPickerController @Inject constructor(
* track, set the renderer's URI, play, then pause local playback so
* audio yields to the speaker. Wrapped in `runCatching` at each
* step — token failure, transport-lookup failure, and SOAP failure
* each abandon the selection cleanly rather than crashing. Errors
* surface to the operator via OkHttp logging for now; spec'd UI
* error surfacing lands in a follow-up if hardware reveals it's
* needed.
* each abandon the selection cleanly rather than crashing. Failures
* log at warn level via Timber so on-device verification can find
* the cause in logcat (OkHttp's logger doesn't cover our own
* deserialize / SOAP-parse code paths).
*/
private suspend fun selectUpnp(route: OutputRoute) {
val trackId = playerController.uiState.value.currentTrack?.id ?: return
@@ -184,6 +185,8 @@ class OutputPickerController @Inject constructor(
transport.setAVTransportURI(token.url)
transport.play()
playerController.pause()
}.onFailure { e ->
Timber.w(e, "UPnP select failed for route ${route.id}")
}
}
@@ -5,7 +5,6 @@ import com.fabledsword.minstrel.di.ApplicationScope
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -45,11 +44,12 @@ class UpnpDiscoveryController @Inject constructor(
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
private var fetchJob: Job? = null
init {
ssdp.start(appScope)
fetchJob = appScope.launch(Dispatchers.IO) {
// 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) }
}
}