mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user