From 29309d9bfb82dee7653c8bfaca838429679ce565 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 17:43:55 -0400 Subject: [PATCH] ui(android): drop snackbar + hardware volume keys when UPnP route active Co-Authored-By: Claude Sonnet 4.6 --- .../minstrel/player/PlayerController.kt | 9 ++ .../player/output/OutputPickerViewModel.kt | 4 + .../minstrel/player/ui/NowPlayingScreen.kt | 100 +++++++++++++++--- .../minstrel/player/ui/PlayerViewModel.kt | 2 + 4 files changed, 102 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 5c71f4a1..908b7e3e 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -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 " to the user. + */ + val dropEvents: SharedFlow = playerFactory.dropEvents private val sessionToken = SessionToken(context, ComponentName(context, MinstrelPlayerService::class.java)) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt index 19362d60..6e3cd81a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerViewModel.kt @@ -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 = controller.routesState @@ -33,6 +34,9 @@ class OutputPickerViewModel @Inject constructor( initialValue = controller.routesState.value, ) + val activeUpnp: StateFlow = activeUpnpHolder.active + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), null) + private val sheetVisibleInternal = MutableStateFlow(false) val sheetVisible: StateFlow = sheetVisibleInternal.asStateFlow() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt index 4115a767..72a648c7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/NowPlayingScreen.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt index b9a32e3f..d7a4952a 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/ui/PlayerViewModel.kt @@ -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 = controller.uiState + val dropEvents: SharedFlow = controller.dropEvents fun play() = controller.play() fun pause() = controller.pause()