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 b3149c03..11940581 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 @@ -2,6 +2,10 @@ package com.fabledsword.minstrel.player.ui +import android.Manifest +import android.content.pm.PackageManager +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.rememberScrollState @@ -44,13 +48,17 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController @@ -66,6 +74,11 @@ 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.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 com.fabledsword.minstrel.nav.AlbumDetail import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER @@ -268,6 +281,32 @@ private fun NowPlayingBody( ) { 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() + + // One-shot BLUETOOTH_CONNECT permission flow. The launcher is + // remembered across recompositions; the LaunchedEffect fires + // exactly once when the sheet opens for the first time per + // composition. Subsequent opens are no-ops; the system remembers + // the user's choice. + val context = LocalContext.current + var permissionDenied by remember { mutableStateOf(false) } + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + permissionDenied = !granted + } + LaunchedEffect(sheetVisible) { + if (sheetVisible && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.BLUETOOTH_CONNECT, + ) != PackageManager.PERMISSION_GRANTED + ) { + permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT) + } + } Column( modifier = Modifier .fillMaxSize() @@ -295,6 +334,17 @@ private fun NowPlayingBody( onToggleShuffle = viewModel::toggleShuffle, onCycleRepeat = viewModel::cycleRepeat, ) + // Spec visibility rule: hide the chip when the only route is the + // built-in speaker — no picker is useful with one option. Chip + // reappears the moment a Bluetooth pair or wired plug arrives. + if (shouldShowChip(routes)) { + Spacer(Modifier.height(8.dp)) + DeviceChip( + route = routes.current, + onClick = outputViewModel::onChipTapped, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } Spacer(Modifier.height(4.dp)) val smoothPositionMs by rememberSmoothPositionMs( positionMs = state.positionMs, @@ -314,6 +364,20 @@ private fun NowPlayingBody( onNext = viewModel::skipToNext, ) } + if (sheetVisible) { + OutputPickerSheet( + snapshot = routes, + permissionDenied = permissionDenied, + onRouteSelected = outputViewModel::onRouteSelected, + onDismiss = outputViewModel::onSheetDismissed, + ) + } +} + +private fun shouldShowChip(snapshot: RouteSnapshot): Boolean { + val onlyBuiltIn = snapshot.available.size == 1 && + snapshot.available.first().kind == OutputRoute.Kind.BuiltIn + return !onlyBuiltIn } @Composable