Merge #63: a display density setting for car apps

# Conflicts:
#	gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt
This commit is contained in:
Seb3thehacker committed 2026-11-04 14:09:14 -08:00
commit 71d8af5128
8 files changed
+145 -7

No files matched your search

@@ -310,7 +310,8 @@ class GearslipActivity : ComponentActivity(), Projection {
val target = surface ?: return
if (screenProjector != null) return
val projector = ScreenProjector(this).also { screenProjector = it }
projector.start(target, frameWidth, frameHeight, frameDensity) { CarUi() }
// The frame's density, not frameDensity: that stays the car's own, for the phone mirror.
projector.start(target, frameWidth, frameHeight, CarEnvironment.frame.value.densityDpi) { CarUi() }
}
/**
@@ -22,6 +22,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
@@ -113,7 +114,11 @@ private fun LauncherScreen(onOpenGearslip: () -> Unit) {
val (width, height) = px
if (width <= 0 || height <= 0) return@BoxWithConstraints
// Car-like text and buttons: 160dpi for every 480 pixels of height, as the car preview uses.
val density = (160 * height / 480).coerceIn(120, 640)
// This screen stands in for the car's, so hosted apps size to it and the density setting applies.
val density = remember(width, height) {
CarEnvironment.setDisplay(width, height, (160 * height / 480).coerceIn(120, 640))
CarEnvironment.frame.value.densityDpi
}
// Before the projector starts, so the car UI's first screen already knows what to hide.
DisposableEffect(Unit) {
LauncherMode.setActive(true)
@@ -37,6 +37,9 @@ object CarEnvironment {
private val frameFlow = MutableStateFlow(Frame(800, 480, 160))
val frame: StateFlow<Frame> = frameFlow
/** The density the car reported; [frame] carries the one the car UI and its apps draw at. */
private var carDensityDpi = 160
val phoneDark: StateFlow<Boolean> = phoneDarkFlow
val darkOutside: StateFlow<Boolean> = darkOutsideFlow
val battery: StateFlow<Battery> = batteryFlow
@@ -95,8 +98,18 @@ object CarEnvironment {
}
fun setDisplay(width: Int, height: Int, densityDpi: Int) {
displayFlow.value = "${width}x$height @ ${densityDpi}dpi"
frameFlow.value = Frame(width, height, densityDpi)
carDensityDpi = densityDpi
val dpi = CarSettings.effectiveDensity(densityDpi)
displayFlow.value = "${width}x$height @ ${dpi}dpi"
frameFlow.value = Frame(width, height, dpi)
}
/**
* Takes up the density setting. It's read only as a session starts, so the display and the
* apps on it can't disagree mid-drive; a session with no car behind it, the preview, asks here.
*/
fun applyDensity() {
frameFlow.value = frameFlow.value.copy(densityDpi = CarSettings.effectiveDensity(carDensityDpi))
}
fun start(context: Context) {
@@ -53,6 +53,7 @@ enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
object CarSettings {
private const val KEY_SCALE = "car_scale"
private const val KEY_DENSITY = "car_density"
private const val KEY_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
@@ -99,6 +100,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
private lateinit var app: Context
private val scaleFlow = MutableStateFlow(1f)
private val carDensityFlow = MutableStateFlow(DENSITY_FROM_CAR)
private val openFlow = MutableStateFlow(HOME)
private val nightFlow = MutableStateFlow(NightMode.AUTO)
private val pipeAudioFlow = MutableStateFlow(true)
@@ -245,6 +247,16 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
val scale: StateFlow<Float> = scaleFlow
/** [carDensity] when it leaves the choice to the car. */
const val DENSITY_FROM_CAR = 0
val DENSITIES = listOf(160, 180, 200, 220, 240)
/** An absolute DPI for the car UI and its apps; the video's resolution doesn't change with it. */
val carDensity: StateFlow<Int> = carDensityFlow
/** The density to draw at, for a car or preview that would otherwise use [carDpi]. */
fun effectiveDensity(carDpi: Int) = carDensityFlow.value.takeIf { it != DENSITY_FROM_CAR } ?: carDpi
val openOnConnect: StateFlow<String> = openFlow
val nightMode: StateFlow<NightMode> = nightFlow
@@ -336,6 +348,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
fun init(context: Context) {
app = context.applicationContext
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
carDensityFlow.value = AppSettings.getString(app, KEY_DENSITY, "").toIntOrNull()?.takeIf { it in DENSITIES } ?: DENSITY_FROM_CAR
openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME)
lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null }
lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null }
@@ -396,6 +409,11 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
AppSettings.putString(app, KEY_SCALE, value.toString())
}
fun setCarDensity(dpi: Int) {
carDensityFlow.value = dpi
AppSettings.putString(app, KEY_DENSITY, dpi.toString())
}
fun setOpenOnConnect(id: String) {
openFlow.value = id
AppSettings.putString(app, KEY_OPEN, id)
@@ -60,7 +60,7 @@ import app.seb3thehacker.gearslip.car.CarUi
@Composable
fun CarPreviewScreen(onBack: () -> Unit) {
val context = LocalContext.current
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
val frame by remember { CarEnvironment.frame.also { CarEnvironment.applyDensity() } }.collectAsStateWithLifecycle()
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
@@ -291,7 +291,7 @@ private fun previewFrame(
size: app.seb3thehacker.gearslip.car.PreviewSize,
custom: app.seb3thehacker.gearslip.car.ScreenSize?,
): Triple<Int, Int, Int> {
fun carLike(w: Int, h: Int) = Triple(w, h, (160 * h / 480).coerceIn(120, 640))
fun carLike(w: Int, h: Int) = Triple(w, h, app.seb3thehacker.gearslip.car.CarSettings.effectiveDensity((160 * h / 480).coerceIn(120, 640)))
return when (size) {
app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM ->
@@ -66,6 +66,8 @@ enum class CarGroup(@param:StringRes val title: Int) {
@Composable
private fun sizeChoices() = listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge))
@Composable
private fun densityChoices() = listOf(CarSettings.DENSITY_FROM_CAR to stringResource(R.string.choice_density_car)) + CarSettings.DENSITIES.map { it to stringResource(R.string.choice_density_dpi, it) }
@Composable
private fun themeChoices() = listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark))
@Composable
private fun darkChoices() = listOf(
@@ -212,6 +214,7 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
@Composable
private fun GroupRows(group: CarGroup) {
val scale by CarSettings.scale.collectAsState()
val density by CarSettings.carDensity.collectAsState()
val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.collectAsState()
val units by CarSettings.units.collectAsState()
@@ -241,6 +244,8 @@ private fun GroupRows(group: CarGroup) {
SettingsDivider()
Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale)
SettingsDivider()
Choice(stringResource(R.string.car_density), densityChoices(), density, CarSettings::setCarDensity, note = stringResource(R.string.car_density_note))
SettingsDivider()
Choice(stringResource(R.string.car_app_theme), themeChoices(), theme, CarSettings::setAppTheme)
SettingsDivider()
if (theme != AppTheme.LIGHT) {
@@ -358,11 +363,12 @@ private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it
/** A setting with a few named choices: its current one shown, the rest a tap away. */
@Composable
private fun <T> Choice(title: String, choices: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
private fun <T> Choice(title: String, choices: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit, note: String? = null) {
var menu by remember { mutableStateOf(false) }
SettingsRow(
title,
Modifier.clickable { menu = true },
subtitle = note,
trailing = {
// The menu hangs from the value it changes, at the row's end.
Box {
+4
View File
@@ -192,6 +192,10 @@
<string name="car_group_same">These are the same settings as Settings in the car. A change here shows there too.</string>
<string name="car_open_on_connect">Open on connect</string>
<string name="car_size">Size</string>
<string name="car_density">Car display density</string>
<string name="car_density_note">Larger text and controls, including in apps. Try 200 DPI with Size on Normal. Reconnect the car or reopen the preview to apply it. If controls clip, choose a lower DPI or From car.</string>
<string name="choice_density_car">From car</string>
<string name="choice_density_dpi">%1$d DPI</string>
<string name="car_app_theme">App theme</string>
<string name="car_dark_mode">Dark mode</string>
<string name="car_buttons">Buttons</string>