ui(android): drop snackbar + hardware volume keys when UPnP route active
android / Build + lint + test (push) Failing after 1m24s
android / Build + lint + test (push) Failing after 1m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
+4
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user