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 val target = surface ?: return
if (screenProjector != null) return if (screenProjector != null) return
val projector = ScreenProjector(this).also { screenProjector = it } 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -113,7 +114,11 @@ private fun LauncherScreen(onOpenGearslip: () -> Unit) {
val (width, height) = px val (width, height) = px
if (width <= 0 || height <= 0) return@BoxWithConstraints if (width <= 0 || height <= 0) return@BoxWithConstraints
// Car-like text and buttons: 160dpi for every 480 pixels of height, as the car preview uses. // 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. // Before the projector starts, so the car UI's first screen already knows what to hide.
DisposableEffect(Unit) { DisposableEffect(Unit) {
LauncherMode.setActive(true) LauncherMode.setActive(true)
@@ -37,6 +37,9 @@ object CarEnvironment {
private val frameFlow = MutableStateFlow(Frame(800, 480, 160)) private val frameFlow = MutableStateFlow(Frame(800, 480, 160))
val frame: StateFlow<Frame> = frameFlow 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 phoneDark: StateFlow<Boolean> = phoneDarkFlow
val darkOutside: StateFlow<Boolean> = darkOutsideFlow val darkOutside: StateFlow<Boolean> = darkOutsideFlow
val battery: StateFlow<Battery> = batteryFlow val battery: StateFlow<Battery> = batteryFlow
@@ -95,8 +98,18 @@ object CarEnvironment {
} }
fun setDisplay(width: Int, height: Int, densityDpi: Int) { fun setDisplay(width: Int, height: Int, densityDpi: Int) {
displayFlow.value = "${width}x$height @ ${densityDpi}dpi" carDensityDpi = densityDpi
frameFlow.value = Frame(width, height, 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) { fun start(context: Context) {
@@ -53,6 +53,7 @@ enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
object CarSettings { object CarSettings {
private const val KEY_SCALE = "car_scale" 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_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode" private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio" 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 lateinit var app: Context
private val scaleFlow = MutableStateFlow(1f) private val scaleFlow = MutableStateFlow(1f)
private val carDensityFlow = MutableStateFlow(DENSITY_FROM_CAR)
private val openFlow = MutableStateFlow(HOME) private val openFlow = MutableStateFlow(HOME)
private val nightFlow = MutableStateFlow(NightMode.AUTO) private val nightFlow = MutableStateFlow(NightMode.AUTO)
private val pipeAudioFlow = MutableStateFlow(true) private val pipeAudioFlow = MutableStateFlow(true)
@@ -245,6 +247,16 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
val scale: StateFlow<Float> = scaleFlow 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 openOnConnect: StateFlow<String> = openFlow
val nightMode: StateFlow<NightMode> = nightFlow val nightMode: StateFlow<NightMode> = nightFlow
@@ -336,6 +348,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
fun init(context: Context) { fun init(context: Context) {
app = context.applicationContext app = context.applicationContext
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f 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) openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME)
lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null } lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null }
lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").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()) 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) { fun setOpenOnConnect(id: String) {
openFlow.value = id openFlow.value = id
AppSettings.putString(app, KEY_OPEN, id) AppSettings.putString(app, KEY_OPEN, id)
@@ -60,7 +60,7 @@ import app.seb3thehacker.gearslip.car.CarUi
@Composable @Composable
fun CarPreviewScreen(onBack: () -> Unit) { fun CarPreviewScreen(onBack: () -> Unit) {
val context = LocalContext.current 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 // 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. // 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 val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
@@ -291,7 +291,7 @@ private fun previewFrame(
size: app.seb3thehacker.gearslip.car.PreviewSize, size: app.seb3thehacker.gearslip.car.PreviewSize,
custom: app.seb3thehacker.gearslip.car.ScreenSize?, custom: app.seb3thehacker.gearslip.car.ScreenSize?,
): Triple<Int, Int, Int> { ): 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) { return when (size) {
app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi) app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM -> app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM ->
@@ -66,6 +66,8 @@ enum class CarGroup(@param:StringRes val title: Int) {
@Composable @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)) 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 @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)) 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 @Composable
private fun darkChoices() = listOf( private fun darkChoices() = listOf(
@@ -212,6 +214,7 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
@Composable @Composable
private fun GroupRows(group: CarGroup) { private fun GroupRows(group: CarGroup) {
val scale by CarSettings.scale.collectAsState() val scale by CarSettings.scale.collectAsState()
val density by CarSettings.carDensity.collectAsState()
val open by CarSettings.openOnConnect.collectAsState() val open by CarSettings.openOnConnect.collectAsState()
val night by CarSettings.nightMode.collectAsState() val night by CarSettings.nightMode.collectAsState()
val units by CarSettings.units.collectAsState() val units by CarSettings.units.collectAsState()
@@ -241,6 +244,8 @@ private fun GroupRows(group: CarGroup) {
SettingsDivider() SettingsDivider()
Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale) Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale)
SettingsDivider() 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) Choice(stringResource(R.string.car_app_theme), themeChoices(), theme, CarSettings::setAppTheme)
SettingsDivider() SettingsDivider()
if (theme != AppTheme.LIGHT) { 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. */ /** A setting with a few named choices: its current one shown, the rest a tap away. */
@Composable @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) } var menu by remember { mutableStateOf(false) }
SettingsRow( SettingsRow(
title, title,
Modifier.clickable { menu = true }, Modifier.clickable { menu = true },
subtitle = note,
trailing = { trailing = {
// The menu hangs from the value it changes, at the row's end. // The menu hangs from the value it changes, at the row's end.
Box { 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_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_open_on_connect">Open on connect</string>
<string name="car_size">Size</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_app_theme">App theme</string>
<string name="car_dark_mode">Dark mode</string> <string name="car_dark_mode">Dark mode</string>
<string name="car_buttons">Buttons</string> <string name="car_buttons">Buttons</string>
@@ -0,0 +1,91 @@
package app.seb3thehacker.gearslip.car
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class CarDensityTest {
private val context = InMemoryContext()
private fun density() = CarEnvironment.frame.value.densityDpi
@Before
fun startFromTheCar() {
CarSettings.init(context)
CarEnvironment.setDisplay(800, 480, 160)
}
@Test
fun fromCarKeepsEachCarsOwnDensity() {
assertEquals(CarSettings.DENSITY_FROM_CAR, CarSettings.carDensity.value)
for (dpi in listOf(120, 180, 213)) {
CarEnvironment.setDisplay(800, 480, dpi)
assertEquals(dpi, density())
}
}
@Test
fun aChosenDensityReachesEverythingThatReadsTheFrame() {
for (dpi in CarSettings.DENSITIES) {
CarSettings.setCarDensity(dpi)
CarEnvironment.setDisplay(800, 480, 160)
assertEquals(dpi, density())
}
}
@Test
fun aChosenDensityLeavesTheResolutionAlone() {
CarSettings.setCarDensity(240)
CarEnvironment.setDisplay(1280, 720, 160)
assertEquals(CarEnvironment.Frame(1280, 720, 240), CarEnvironment.frame.value)
}
@Test
fun aNewChoiceWaitsForTheNextSession() {
CarSettings.setCarDensity(200)
CarEnvironment.setDisplay(800, 480, 160)
CarSettings.setCarDensity(240)
assertEquals(200, density())
CarEnvironment.setDisplay(800, 480, 160)
assertEquals(240, density())
}
@Test
fun thePreviewTakesUpANewChoiceWhenItOpens() {
CarSettings.setCarDensity(200)
CarEnvironment.applyDensity()
assertEquals(200, density())
}
@Test
fun goingBackToFromCarRestoresTheCarsOwnDensity() {
CarSettings.setCarDensity(240)
CarEnvironment.setDisplay(800, 480, 180)
CarSettings.setCarDensity(CarSettings.DENSITY_FROM_CAR)
CarEnvironment.applyDensity()
assertEquals(180, density())
}
@Test
fun aPreviewSizedForThePhoneFollowsTheChoiceToo() {
assertEquals(213, CarSettings.effectiveDensity(213))
CarSettings.setCarDensity(200)
assertEquals(200, CarSettings.effectiveDensity(213))
}
@Test
fun theChoiceSurvivesARestart() {
CarSettings.setCarDensity(220)
CarSettings.init(context)
assertEquals(220, CarSettings.carDensity.value)
}
@Test
fun aSavedDensityGearslipDoesNotOfferFallsBackToTheCar() {
for (saved in listOf("201", "-1", "broken", "")) {
context.stored["car_density"] = saved
CarSettings.init(context)
assertEquals(CarSettings.DENSITY_FROM_CAR, CarSettings.carDensity.value)
}
}
}