ui(android): drop snackbar + hardware volume keys when UPnP route active
android / Build + lint + test (push) Failing after 1m24s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 17:43:55 -04:00
parent 70b29567fb
commit 29309d9bfb
4 changed files with 102 additions and 13 deletions
@@ -20,6 +20,7 @@ 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.receiveAsFlow
@@ -59,7 +60,15 @@ class PlayerController @Inject constructor(
@ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope,
private val radio: RadioController,
private val playerFactory: PlayerFactory,
) {
/**
* 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))
@@ -24,6 +24,7 @@ import javax.inject.Inject
@HiltViewModel
class OutputPickerViewModel @Inject constructor(
private val controller: OutputPickerController,
private val activeUpnpHolder: ActiveUpnpHolder,
) : ViewModel() {
val routes: StateFlow<RouteSnapshot> = controller.routesState
@@ -33,6 +34,9 @@ class OutputPickerViewModel @Inject constructor(
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()
@@ -8,6 +8,7 @@ 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
@@ -50,10 +51,19 @@ 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
@@ -74,11 +84,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
@@ -129,27 +141,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,
@@ -166,11 +181,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
@@ -270,6 +306,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,
@@ -278,10 +315,10 @@ private fun NowPlayingBody(
navController: NavHostController,
viewModel: PlayerViewModel,
trackActionsViewModel: TrackActionsViewModel,
outputViewModel: OutputPickerViewModel,
) {
val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false)
val outputViewModel: OutputPickerViewModel = hiltViewModel()
val routes by outputViewModel.routes.collectAsStateWithLifecycle()
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
@@ -703,3 +740,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 { 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()