v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation #77

Merged
bvandeusen merged 29 commits from dev into main 2026-06-03 14:09:23 -04:00
Showing only changes of commit 8258b6c29f - Show all commits
@@ -2,6 +2,10 @@
package com.fabledsword.minstrel.player.ui 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.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -44,13 +48,17 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController 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.SkipBack
import com.composables.icons.lucide.SkipForward import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.player.RepeatMode 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.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
@@ -268,6 +281,32 @@ private fun NowPlayingBody(
) { ) {
val isLiked by trackActionsViewModel.isLikedFlow(track.id) val isLiked by trackActionsViewModel.isLikedFlow(track.id)
.collectAsStateWithLifecycle(initialValue = false) .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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -295,6 +334,17 @@ private fun NowPlayingBody(
onToggleShuffle = viewModel::toggleShuffle, onToggleShuffle = viewModel::toggleShuffle,
onCycleRepeat = viewModel::cycleRepeat, 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)) Spacer(Modifier.height(4.dp))
val smoothPositionMs by rememberSmoothPositionMs( val smoothPositionMs by rememberSmoothPositionMs(
positionMs = state.positionMs, positionMs = state.positionMs,
@@ -314,6 +364,20 @@ private fun NowPlayingBody(
onNext = viewModel::skipToNext, 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 @Composable