From a319e3f66dd795b719291790bde09aa3b9178ad1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 3 Jun 2026 10:35:48 -0400 Subject: [PATCH] feat(android): output picker Compose UI - chip + sheet Bluetooth slice (4/5). DeviceChip: Spotify-style current-route indicator with icon + name + chevron, single-line ellipsis on long names. iconFor() maps Kind to Lucide icons (Smartphone / Headphones / Bluetooth / Cast / Speaker). OutputPickerSheet: Material 3 ModalBottomSheet. Header 'Output', rows = icon + name + 2-line subtitle + selection state (CircleCheck accent for selected, Circle outline otherwise). Tap selects + dismisses. permissionDenied flag controls a footer hint row when BLUETOOTH_CONNECT was refused. NowPlayingScreen wiring lands in the final commit. --- .../minstrel/player/output/DeviceChip.kt | 84 ++++++++++ .../player/output/OutputPickerSheet.kt | 156 ++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/DeviceChip.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/DeviceChip.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/DeviceChip.kt new file mode 100644 index 00000000..ebe28ca4 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/DeviceChip.kt @@ -0,0 +1,84 @@ +package com.fabledsword.minstrel.player.output + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Bluetooth +import com.composables.icons.lucide.Cast +import com.composables.icons.lucide.ChevronDown +import com.composables.icons.lucide.Headphones +import com.composables.icons.lucide.Lucide +import com.composables.icons.lucide.Smartphone +import com.composables.icons.lucide.Speaker + +/** + * Spotify-style chip showing the current output route. Sits between + * BottomActionsRow and ScrubberRow in NowPlayingScreen. Tap to open + * the picker sheet. + * + * Visibility rule per the spec: hidden when the route list has + * exactly one entry AND that entry is BuiltIn — no reason to surface + * a picker for "the only thing available." Visibility logic owned + * by the caller (NowPlayingScreen). + */ +@Composable +fun DeviceChip( + route: OutputRoute, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = iconFor(route.kind), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(CHIP_ICON_DP.dp), + ) + Text( + text = route.name, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = Lucide.ChevronDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(CHIP_CHEVRON_DP.dp), + ) + } + } +} + +internal fun iconFor(kind: OutputRoute.Kind): ImageVector = when (kind) { + OutputRoute.Kind.BuiltIn -> Lucide.Smartphone + OutputRoute.Kind.Wired -> Lucide.Headphones + OutputRoute.Kind.Bluetooth -> Lucide.Bluetooth + OutputRoute.Kind.Cast -> Lucide.Cast + OutputRoute.Kind.Other -> Lucide.Speaker +} + +private const val CHIP_ICON_DP = 16 +private const val CHIP_CHEVRON_DP = 14 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt new file mode 100644 index 00000000..88a80e99 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/output/OutputPickerSheet.kt @@ -0,0 +1,156 @@ +package com.fabledsword.minstrel.player.output + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.composables.icons.lucide.Circle +import com.composables.icons.lucide.CircleCheck +import com.composables.icons.lucide.Lucide + +/** + * Material 3 ModalBottomSheet listing the available output routes. + * Tap a row to select + dismiss. Selected route shows the accent + * CircleCheck; others show an empty Circle. Long route names + * truncate cleanly via maxLines = 2 + Ellipsis. + * + * Permission denial footer hint is intentionally NOT inlined here — + * NowPlayingScreen owns the BLUETOOTH_CONNECT request flow and + * passes a [permissionDenied] flag to control whether the hint + * renders. Keeps this composable focused on rendering. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutputPickerSheet( + snapshot: RouteSnapshot, + permissionDenied: Boolean, + onRouteSelected: (OutputRoute) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Output", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(vertical = 8.dp), + ) + snapshot.available.forEach { route -> + RouteRow( + route = route, + isSelected = route.id == snapshot.current.id, + onClick = { onRouteSelected(route) }, + ) + } + if (permissionDenied) { + PermissionHintRow() + } + } + } +} + +@Composable +private fun RouteRow( + route: OutputRoute, + isSelected: Boolean, + onClick: () -> Unit, +) { + val tint = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Surface( + onClick = onClick, + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = iconFor(route.kind), + contentDescription = null, + tint = tint, + modifier = Modifier.size(ROW_ICON_DP.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = route.name, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = route.description ?: defaultSubtitle(route) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = if (isSelected) Lucide.CircleCheck else Lucide.Circle, + contentDescription = if (isSelected) "Selected" else "Not selected", + tint = tint, + modifier = Modifier.size(ROW_ICON_DP.dp), + ) + } + } +} + +@Composable +private fun PermissionHintRow() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Pair a Bluetooth device in Settings to see it here.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) { + OutputRoute.Kind.BuiltIn -> "Phone speaker" + OutputRoute.Kind.Wired -> "Wired" + OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available" + OutputRoute.Kind.Cast -> "Cast" + OutputRoute.Kind.Other -> "Available" +} + +private const val ROW_ICON_DP = 24