v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation #77
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user