feat(android): NowPlaying output-picker integration
android / Build + lint + test (push) Failing after 1m33s

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.
This commit is contained in:
2026-06-03 10:42:30 -04:00
parent d10113db54
commit 8258b6c29f
@@ -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