mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
home: drag the player card's edge to resize it, and the cover fills whatever the controls leave
The width is saved. The controls sit along the bottom of the card.
This commit is contained in:
1 parent
93a1ff7485
commit
ef1b2e1be7
2 files changed
+84
-18
No files matched your search
@@ -27,6 +27,7 @@ import app.seb3thehacker.gearslip.R
|
|||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.mutableLongStateOf
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
@@ -105,11 +106,19 @@ fun CarHome() {
|
|||||||
// The map runs edge to edge underneath; the player floats over one side of it as a card, with
|
// The map runs edge to edge underneath; the player floats over one side of it as a card, with
|
||||||
// a gap all round. The map's surface keeps its size when the card comes and goes, so the app
|
// a gap all round. The map's surface keeps its size when the card comes and goes, so the app
|
||||||
// only re-centres its map instead of drawing onto a new surface.
|
// only re-centres its map instead of drawing onto a new surface.
|
||||||
|
val savedWidth by CarSettings.playerWidth.collectAsStateWithLifecycle()
|
||||||
|
// The width while the grip is being dragged; null otherwise.
|
||||||
|
var dragging by remember { androidx.compose.runtime.mutableStateOf<androidx.compose.ui.unit.Dp?>(null) }
|
||||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
val column = (maxWidth / PLAYER_SHARE).coerceAtMost(PLAYER_MAX_WIDTH)
|
val widest = maxWidth * PLAYER_MAX_SHARE
|
||||||
|
val settled = (if (savedWidth > 0f) savedWidth.dp else (maxWidth / PLAYER_SHARE).coerceAtMost(PLAYER_DEFAULT_MAX))
|
||||||
|
.coerceIn(PLAYER_MIN_WIDTH, widest)
|
||||||
|
val column = dragging ?: settled
|
||||||
androidx.compose.runtime.CompositionLocalProvider(
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
LocalCardsAtEnd provides (splitScreen && playerLeft),
|
LocalCardsAtEnd provides (splitScreen && playerLeft),
|
||||||
LocalSidePanelInset provides if (splitScreen) column else 0.dp,
|
// The settled width, not the one under the finger: the map app re-centres once, when
|
||||||
|
// the drag ends, rather than on every frame of it.
|
||||||
|
LocalSidePanelInset provides if (splitScreen) settled else 0.dp,
|
||||||
) {
|
) {
|
||||||
MapPane(navStatus, frame)
|
MapPane(navStatus, frame)
|
||||||
}
|
}
|
||||||
@@ -130,16 +139,63 @@ fun CarHome() {
|
|||||||
top = CARD_GAP,
|
top = CARD_GAP,
|
||||||
bottom = LocalNavBarInset.current + CARD_GAP,
|
bottom = LocalNavBarInset.current + CARD_GAP,
|
||||||
)
|
)
|
||||||
if (shown.value == SidePanel.LYRICS) SideLyrics(now, card) else DockedPlayer(now, card)
|
Box(card) {
|
||||||
|
val fill = Modifier.fillMaxSize()
|
||||||
|
if (shown.value == SidePanel.LYRICS) SideLyrics(now, fill) else DockedPlayer(now, fill)
|
||||||
|
ResizeGrip(
|
||||||
|
onDrag = { delta ->
|
||||||
|
val grown = if (playerLeft) delta else -delta
|
||||||
|
dragging = ((dragging ?: settled) + grown).coerceIn(PLAYER_MIN_WIDTH, widest)
|
||||||
|
},
|
||||||
|
onDone = {
|
||||||
|
dragging?.let { CarSettings.setPlayerWidth(it.value) }
|
||||||
|
dragging = null
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(if (playerLeft) Alignment.CenterEnd else Alignment.CenterStart),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The player takes one part in this many of the screen's width, up to [PLAYER_MAX_WIDTH]. */
|
/** Until it's dragged, the player takes one part in this many of the screen's width, up to [PLAYER_DEFAULT_MAX]. */
|
||||||
private const val PLAYER_SHARE = 3.6f
|
private const val PLAYER_SHARE = 3.6f
|
||||||
|
|
||||||
/** On a wide screen the card stops growing, so the map gets the extra room. */
|
/** On a wide screen the default card stops growing, so the map gets the extra room. */
|
||||||
private val PLAYER_MAX_WIDTH = 340.dp
|
private val PLAYER_DEFAULT_MAX = 340.dp
|
||||||
|
|
||||||
|
/** How far the grip can take the card: narrow enough for the controls, wide enough to leave a map. */
|
||||||
|
private val PLAYER_MIN_WIDTH = 240.dp
|
||||||
|
private const val PLAYER_MAX_SHARE = 0.5f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The handle along the card's edge facing the map: a short bar to show it's there, in a strip wide
|
||||||
|
* enough for a finger. [onDrag] gets each move in dp, positive to the right.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ResizeGrip(onDrag: (androidx.compose.ui.unit.Dp) -> Unit, onDone: () -> Unit, modifier: Modifier) {
|
||||||
|
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||||
|
val state = androidx.compose.foundation.gestures.rememberDraggableState { px -> onDrag(with(density) { px.toDp() }) }
|
||||||
|
Box(
|
||||||
|
// Only the middle of the edge, so it stays clear of the close button in the corner.
|
||||||
|
modifier
|
||||||
|
.height(140.dp)
|
||||||
|
.width(28.dp)
|
||||||
|
.draggable(
|
||||||
|
state,
|
||||||
|
androidx.compose.foundation.gestures.Orientation.Horizontal,
|
||||||
|
onDragStopped = { onDone() },
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(width = 5.dp, height = 44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Between the player card and the screen's edges, and the map's own cards beside it. */
|
/** Between the player card and the screen's edges, and the map's own cards beside it. */
|
||||||
private val CARD_GAP = 12.dp
|
private val CARD_GAP = 12.dp
|
||||||
@@ -199,8 +255,8 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player, docked beside the map: art, title, progress, transport, then like, shuffle, repeat
|
* The player, docked beside the map: the art as large as it fits, then title, progress, transport,
|
||||||
* and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show
|
* and like, shuffle, repeat and lyrics along the bottom. While it's here the nav bar's pill steps aside on Home, so the controls never show
|
||||||
* twice. The X sends it back to the nav bar.
|
* twice. The X sends it back to the nav bar.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -214,17 +270,16 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
|||||||
// The theme's surface rather than the art's colour: a slab of album colour beside the map
|
// The theme's surface rather than the art's colour: a slab of album colour beside the map
|
||||||
// fought it for attention, and the art says the same thing in less space.
|
// fought it for attention, and the art says the same thing in less space.
|
||||||
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
|
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
|
||||||
BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) {
|
Box(Modifier.fillMaxSize().padding(12.dp)) {
|
||||||
// Whatever height the controls below leave over, so the art grows on a taller
|
|
||||||
// screen and shrinks rather than pushing the buttons off a short one.
|
|
||||||
val artSize = minOf(maxWidth - 44.dp, maxHeight - 290.dp).coerceIn(56.dp, 200.dp)
|
|
||||||
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Box(Modifier.fillMaxWidth()) {
|
// The cover gets everything the controls leave, as big a square as fits, sitting
|
||||||
|
// right on top of the title; the controls stay pinned to the bottom.
|
||||||
|
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.BottomCenter) {
|
||||||
|
val artSize = minOf(maxWidth, maxHeight).coerceAtLeast(0.dp)
|
||||||
// Art opens the full media screen, same as on the pill.
|
// Art opens the full media screen, same as on the pill.
|
||||||
val coverShape = RoundedCornerShape(16.dp)
|
val coverShape = RoundedCornerShape(16.dp)
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.size(artSize)
|
.size(artSize)
|
||||||
.gsPanelShadow(coverShape)
|
.gsPanelShadow(coverShape)
|
||||||
.clip(coverShape)
|
.clip(coverShape)
|
||||||
@@ -236,10 +291,6 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
|||||||
shown?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
shown?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GsIconButton(
|
|
||||||
Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel,
|
|
||||||
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -264,6 +315,10 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
GsIconButton(
|
||||||
|
Icons.Filled.Close, "Back to the nav bar", navigator::closeSidePanel,
|
||||||
|
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||||
private const val KEY_PLAYER_TITLE = "car_player_title"
|
private const val KEY_PLAYER_TITLE = "car_player_title"
|
||||||
private const val KEY_PLAYER_LEFT = "car_player_left"
|
private const val KEY_PLAYER_LEFT = "car_player_left"
|
||||||
|
private const val KEY_PLAYER_WIDTH = "car_player_width"
|
||||||
private const val KEY_MORE_LYRICS = "car_more_lyrics"
|
private const val KEY_MORE_LYRICS = "car_more_lyrics"
|
||||||
private const val KEY_THEME = "car_app_theme"
|
private const val KEY_THEME = "car_app_theme"
|
||||||
private const val KEY_UI_THEME = "car_ui_theme"
|
private const val KEY_UI_THEME = "car_ui_theme"
|
||||||
@@ -105,6 +106,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
private val autoplayFlow = MutableStateFlow(false)
|
private val autoplayFlow = MutableStateFlow(false)
|
||||||
private val playerTitleFlow = MutableStateFlow(false)
|
private val playerTitleFlow = MutableStateFlow(false)
|
||||||
private val playerLeftFlow = MutableStateFlow(false)
|
private val playerLeftFlow = MutableStateFlow(false)
|
||||||
|
private val playerWidthFlow = MutableStateFlow(0f)
|
||||||
private val moreLyricsFlow = MutableStateFlow(true)
|
private val moreLyricsFlow = MutableStateFlow(true)
|
||||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||||
@@ -226,6 +228,9 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
/** The home screen's player sits left of the map instead of right of it. */
|
/** The home screen's player sits left of the map instead of right of it. */
|
||||||
val playerLeft: StateFlow<Boolean> = playerLeftFlow
|
val playerLeft: StateFlow<Boolean> = playerLeftFlow
|
||||||
|
|
||||||
|
/** The docked player's width in dp, as last dragged; 0 until it has been, for the default. */
|
||||||
|
val playerWidth: StateFlow<Float> = playerWidthFlow
|
||||||
|
|
||||||
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
|
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
|
||||||
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
|
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
|
||||||
|
|
||||||
@@ -321,6 +326,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||||
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
||||||
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
|
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
|
||||||
|
playerWidthFlow.value = AppSettings.getString(app, KEY_PLAYER_WIDTH, "0").toFloatOrNull() ?: 0f
|
||||||
moreLyricsFlow.value = AppSettings.getString(app, KEY_MORE_LYRICS, "true") == "true"
|
moreLyricsFlow.value = AppSettings.getString(app, KEY_MORE_LYRICS, "true") == "true"
|
||||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
|
||||||
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
||||||
@@ -399,6 +405,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
|
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setPlayerWidth(dp: Float) {
|
||||||
|
playerWidthFlow.value = dp
|
||||||
|
AppSettings.putString(app, KEY_PLAYER_WIDTH, dp.toString())
|
||||||
|
}
|
||||||
|
|
||||||
fun setMoreLyrics(on: Boolean) {
|
fun setMoreLyrics(on: Boolean) {
|
||||||
moreLyricsFlow.value = on
|
moreLyricsFlow.value = on
|
||||||
AppSettings.putString(app, KEY_MORE_LYRICS, on.toString())
|
AppSettings.putString(app, KEY_MORE_LYRICS, on.toString())
|
||||||
|
|||||||
Reference in new issue
Block a user