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.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
@@ -59,7 +60,15 @@ class PlayerController @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
private val radio: RadioController, 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 = private val sessionToken =
SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java)) SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java))
@@ -24,6 +24,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class OutputPickerViewModel @Inject constructor( class OutputPickerViewModel @Inject constructor(
private val controller: OutputPickerController, private val controller: OutputPickerController,
private val activeUpnpHolder: ActiveUpnpHolder,
) : ViewModel() { ) : ViewModel() {
val routes: StateFlow<RouteSnapshot> = controller.routesState val routes: StateFlow<RouteSnapshot> = controller.routesState
@@ -33,6 +34,9 @@ class OutputPickerViewModel @Inject constructor(
initialValue = controller.routesState.value, initialValue = controller.routesState.value,
) )
val activeUpnp: StateFlow<ActiveUpnp?> = activeUpnpHolder.active
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null)
private val sheetVisibleInternal = MutableStateFlow(false) private val sheetVisibleInternal = MutableStateFlow(false)
val sheetVisible: StateFlow<Boolean> = sheetVisibleInternal.asStateFlow() val sheetVisible: StateFlow<Boolean> = sheetVisibleInternal.asStateFlow()
@@ -8,6 +8,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -50,10 +51,19 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment 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.Modifier
import androidx.compose.ui.draw.clip 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.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.SkipBack
import com.composables.icons.lucide.SkipForward import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.player.RepeatMode 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.DeviceChip
import com.fabledsword.minstrel.player.output.OutputPickerSheet import com.fabledsword.minstrel.player.output.OutputPickerSheet
import com.fabledsword.minstrel.player.output.OutputPickerViewModel import com.fabledsword.minstrel.player.output.OutputPickerViewModel
import com.fabledsword.minstrel.player.output.OutputRoute import com.fabledsword.minstrel.player.output.OutputRoute
import com.fabledsword.minstrel.player.output.RouteSnapshot import com.fabledsword.minstrel.player.output.RouteSnapshot
import kotlinx.coroutines.launch
import com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
@@ -129,27 +141,30 @@ fun NowPlayingScreen(
snackbarHostState.showSnackbar(msg) snackbarHostState.showSnackbar(msg)
} }
} }
LaunchedEffect(Unit) {
viewModel.dropEvents.collect { snackbarHostState.showSnackbar("Disconnected from $it") }
}
val track = state.currentTrack val track = state.currentTrack
if (track == null) { if (track == null) {
// Session torn down (queue finished + auto-stop, or user cleared NowPlayingNullTrackGuard(navController, viewModel)
// 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()
}
}
return 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 dominant = rememberDominantColor(track.coverUrl)
val dismissConnection = rememberDragDismissConnection( val dismissConnection = rememberDragDismissConnection(
onDismiss = { navController.popBackStack() }, onDismiss = { navController.popBackStack() },
) )
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(dismissConnection), modifier = Modifier
.fillMaxSize()
.nestedScroll(dismissConnection)
.focusRequester(focusRequester)
.focusable()
.onKeyEvent(onKeyEvent),
topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) }, topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -166,11 +181,32 @@ fun NowPlayingScreen(
navController = navController, navController = navController,
viewModel = viewModel, viewModel = viewModel,
trackActionsViewModel = trackActionsViewModel, 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 @Composable
private fun dominantGradient(top: Color): Brush { private fun dominantGradient(top: Color): Brush {
val base = MaterialTheme.colorScheme.background val base = MaterialTheme.colorScheme.background
@@ -270,6 +306,7 @@ private fun NowPlayingTopBar(onClose: () -> Unit) {
) )
} }
@Suppress("LongParameterList") // Compose screen wiring — layout args, not logic
@Composable @Composable
private fun NowPlayingBody( private fun NowPlayingBody(
inner: androidx.compose.foundation.layout.PaddingValues, inner: androidx.compose.foundation.layout.PaddingValues,
@@ -278,10 +315,10 @@ private fun NowPlayingBody(
navController: NavHostController, navController: NavHostController,
viewModel: PlayerViewModel, viewModel: PlayerViewModel,
trackActionsViewModel: TrackActionsViewModel, trackActionsViewModel: TrackActionsViewModel,
outputViewModel: OutputPickerViewModel,
) { ) {
val isLiked by trackActionsViewModel.isLikedFlow(track.id) val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
val outputViewModel: OutputPickerViewModel = hiltViewModel()
val routes by outputViewModel.routes.collectAsStateWithLifecycle() val routes by outputViewModel.routes.collectAsStateWithLifecycle()
val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle() val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
val permissionDenied = rememberBluetoothPermissionState(sheetVisible) 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.PlayerController
import com.fabledsword.minstrel.player.PlayerUiState import com.fabledsword.minstrel.player.PlayerUiState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject import javax.inject.Inject
@@ -24,6 +25,7 @@ class PlayerViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
val uiState: StateFlow<PlayerUiState> = controller.uiState val uiState: StateFlow<PlayerUiState> = controller.uiState
val dropEvents: SharedFlow<String> = controller.dropEvents
fun play() = controller.play() fun play() = controller.play()
fun pause() = controller.pause() fun pause() = controller.pause()