feat(android): NowPlaying output-picker integration
android / Build + lint + test (push) Failing after 1m33s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user