diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt
index c267e05..cd31e67 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt
@@ -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() }
}
/**
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/LauncherActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/LauncherActivity.kt
index 07ab03c..27d6ed1 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/LauncherActivity.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/LauncherActivity.kt
@@ -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)
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt
index 103064b..cd145cb 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarEnvironment.kt
@@ -37,6 +37,9 @@ object CarEnvironment {
private val frameFlow = MutableStateFlow(Frame(800, 480, 160))
val frame: StateFlow = 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 = phoneDarkFlow
val darkOutside: StateFlow = darkOutsideFlow
val battery: StateFlow = 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) {
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
index f54b938..55be30b 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt
@@ -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 = moreLyricsFlow
val scale: StateFlow = 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 = 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 = openFlow
val nightMode: StateFlow = 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)
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt
index 01cdd8e..90c0786 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt
@@ -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 {
- 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 ->
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt
index ca917e5..87543a8 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt
@@ -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 List>.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 Choice(title: String, choices: List>, selected: T, onSelect: (T) -> Unit) {
+private fun Choice(title: String, choices: List>, 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 {
diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml
index a17f078..d0c0fe5 100644
--- a/gearslip/src/main/res/values/strings.xml
+++ b/gearslip/src/main/res/values/strings.xml
@@ -192,6 +192,10 @@
These are the same settings as Settings in the car. A change here shows there too.
Open on connect
Size
+ Car display density
+ 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.
+ From car
+ %1$d DPI
App theme
Dark mode
Buttons
diff --git a/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarDensityTest.kt b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarDensityTest.kt
new file mode 100644
index 0000000..0d874f2
--- /dev/null
+++ b/gearslip/src/test/java/app/seb3thehacker/gearslip/car/CarDensityTest.kt
@@ -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)
+ }
+ }
+}