From 8258b6c29f99acf617f35d731e5eb4a6ec8f22b2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 10:42:30 -0400 Subject: [PATCH] feat(android): NowPlaying output-picker integration Bluetooth slice (5/5). Wires the OutputPickerViewModel + chip + sheet into NowPlayingScreen. - Chip renders between BottomActionsRow and ScrubberRow, hidden via shouldShowChip() when the only route is the built-in speaker (no useful picker with one option). - Sheet appears on chip tap; selecting a route or dismissing flips the ViewModel state and downgrades MediaRouter discovery. - BLUETOOTH_CONNECT permission requested via the modern ActivityResultContracts.RequestPermission() pattern on first sheet open. permissionDenied flag passed through to the sheet so the 'pair in Settings' hint renders when refused. Closes the Bluetooth slice spec'd in docs/superpowers/specs/2026-06-03-android-output-picker-bluetooth-design.md. On-device verification still pending: pair a Bluetooth speaker, confirm chip + sheet + select + audio routes; verify wired plug auto-update + permission-denial hint + long-name truncation. --- .../minstrel/player/ui/NowPlayingScreen.kt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) 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