screen size: fix a car that reports the wrong size, and size the preview

Some head units report a size their screen isn't, then crop or squash the
picture onto it. Settings, on the phone and the car, now take the screen's
real size and how the car fits it. Crop keeps the UI and taps inside the
part the car shows, like a car's own margins. Squash draws for the real
shape and stretches it over the frame on the GPU, map apps included, so
the car's squash puts it back.

The car preview can also draw at the last car's size, this device's whole
screen, or a size typed in, for head units running Gearslip on their own.
This commit is contained in:
Seb3thehacker committed 2026-10-19 22:19:18 -07:00
1 parent 86a1f968ec
commit 18571622d8
7 files changed
+541 -8

No files matched your search

@@ -0,0 +1,190 @@
package app.seb3thehacker.gearslip
import android.graphics.SurfaceTexture
import android.opengl.EGL14
import android.opengl.EGLConfig
import android.opengl.EGLContext
import android.opengl.EGLDisplay
import android.opengl.EGLExt
import android.opengl.EGLSurface
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.os.Handler
import android.os.HandlerThread
import android.view.Surface
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Stretches a [width] x [height] picture over the whole of an [outWidth] x [outHeight] encoder
* frame.
*
* For a head unit that squashes the frame onto a screen of another shape: Gearslip draws for the
* screen's real shape into [input], this stretches it the other way, and the car's squash brings
* it back. It works on the composited picture, so a map app's own surface is stretched with
* everything else - scaling Gearslip's UI alone would leave the map squashed.
*/
class FrameStretcher(
private val output: Surface,
val width: Int,
val height: Int,
private val outWidth: Int,
private val outHeight: Int,
) {
private val thread = HandlerThread("gearslip-stretch").apply { start() }
private val handler = Handler(thread.looper)
private var display: EGLDisplay = EGL14.EGL_NO_DISPLAY
private var context: EGLContext = EGL14.EGL_NO_CONTEXT
private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE
private var program = 0
private var texture: SurfaceTexture? = null
private val transform = FloatArray(16)
/** Where to draw. Valid once [start] returns true. */
lateinit var input: Surface
private set
/** Sets up on its own thread and waits for it; false if the GPU wouldn't play along. */
fun start(): Boolean {
val done = CountDownLatch(1)
var ok = false
handler.post {
ok = runCatching { setUp() }
.onFailure { GearslipLog.e("stretch: could not set up", it) }
.isSuccess
done.countDown()
}
done.await(2, TimeUnit.SECONDS)
if (!ok) release()
return ok
}
fun release() {
handler.post {
runCatching { if (::input.isInitialized) input.release() }
runCatching { texture?.release() }
texture = null
if (display != EGL14.EGL_NO_DISPLAY) {
EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT)
if (eglSurface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, eglSurface)
if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context)
EGL14.eglTerminate(display)
}
display = EGL14.EGL_NO_DISPLAY
thread.quitSafely()
}
}
private fun setUp() {
display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY)
val version = IntArray(2)
check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" }
val configs = arrayOfNulls<EGLConfig>(1)
val count = IntArray(1)
val attributes = intArrayOf(
EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8,
EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
EGL_RECORDABLE_ANDROID, 1,
EGL14.EGL_NONE,
)
check(EGL14.eglChooseConfig(display, attributes, 0, configs, 0, 1, count, 0) && count[0] > 0) {
"no EGL config"
}
context = EGL14.eglCreateContext(
display, configs[0], EGL14.EGL_NO_CONTEXT,
intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE), 0,
)
eglSurface = EGL14.eglCreateWindowSurface(display, configs[0], output, intArrayOf(EGL14.EGL_NONE), 0)
check(EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context)) { "eglMakeCurrent failed" }
program = link(VERTEX, FRAGMENT)
val ids = IntArray(1)
GLES20.glGenTextures(1, ids, 0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, ids[0])
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
texture = SurfaceTexture(ids[0]).apply {
setDefaultBufferSize(width, height)
setOnFrameAvailableListener({ draw() }, handler)
}
input = Surface(texture)
GearslipLog.i("stretch: drawing ${width}x$height, stretched to fill ${outWidth}x$outHeight")
}
private fun draw() {
val tex = texture ?: return
runCatching {
tex.updateTexImage()
tex.getTransformMatrix(transform)
GLES20.glViewport(0, 0, outWidth, outHeight)
GLES20.glUseProgram(program)
val position = GLES20.glGetAttribLocation(program, "aPosition")
val coord = GLES20.glGetAttribLocation(program, "aCoord")
GLES20.glEnableVertexAttribArray(position)
GLES20.glVertexAttribPointer(position, 2, GLES20.GL_FLOAT, false, 0, QUAD)
GLES20.glEnableVertexAttribArray(coord)
GLES20.glVertexAttribPointer(coord, 2, GLES20.GL_FLOAT, false, 0, COORDS)
GLES20.glUniformMatrix4fv(GLES20.glGetUniformLocation(program, "uTransform"), 1, false, transform, 0)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
EGLExt.eglPresentationTimeANDROID(display, eglSurface, tex.timestamp)
EGL14.eglSwapBuffers(display, eglSurface)
}.onFailure { GearslipLog.e("stretch: draw failed", it) }
}
private fun link(vertex: String, fragment: String): Int {
fun compile(type: Int, source: String): Int = GLES20.glCreateShader(type).also {
GLES20.glShaderSource(it, source)
GLES20.glCompileShader(it)
val status = IntArray(1)
GLES20.glGetShaderiv(it, GLES20.GL_COMPILE_STATUS, status, 0)
check(status[0] != 0) { "shader: ${GLES20.glGetShaderInfoLog(it)}" }
}
return GLES20.glCreateProgram().also {
GLES20.glAttachShader(it, compile(GLES20.GL_VERTEX_SHADER, vertex))
GLES20.glAttachShader(it, compile(GLES20.GL_FRAGMENT_SHADER, fragment))
GLES20.glLinkProgram(it)
val status = IntArray(1)
GLES20.glGetProgramiv(it, GLES20.GL_LINK_STATUS, status, 0)
check(status[0] != 0) { "program: ${GLES20.glGetProgramInfoLog(it)}" }
}
}
private companion object {
/** EGL_RECORDABLE_ANDROID: the config must be able to feed a video encoder. */
const val EGL_RECORDABLE_ANDROID = 0x3142
val QUAD = floats(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)
val COORDS = floats(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)
fun floats(vararg values: Float) = ByteBuffer.allocateDirect(values.size * 4)
.order(ByteOrder.nativeOrder()).asFloatBuffer().apply { put(values); position(0) }
const val VERTEX = """
attribute vec4 aPosition;
attribute vec4 aCoord;
uniform mat4 uTransform;
varying vec2 vCoord;
void main() {
gl_Position = aPosition;
vCoord = (uTransform * aCoord).xy;
}
"""
const val FRAGMENT = """
#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vCoord;
uniform samplerExternalOES sTexture;
void main() {
gl_FragColor = texture2D(sTexture, vCoord);
}
"""
}
}
@@ -13,6 +13,7 @@ import app.seb3thehacker.gearslip.car.DirectionsVoice
import app.seb3thehacker.gearslip.car.CarAssistant
import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarKeys
import app.seb3thehacker.gearslip.car.ScreenFit
import app.seb3thehacker.gearslip.car.CarSensors
import app.seb3thehacker.gearslip.car.CarServices
import app.seb3thehacker.gearslip.car.CarSettings
@@ -72,6 +73,12 @@ class GearslipRunner(
private var vehicleProfile: VehicleProfile? = null
@Volatile private var videoStarted = false
private var videoSource: VideoSource? = null
/** Between the car UI and the encoder when the car squashes the frame (see [screenShape]). */
private var stretcher: FrameStretcher? = null
/** What the car UI draws at when [stretcher] is on; touches are scaled into it. */
@Volatile private var drawSize: Pair<Int, Int>? = null
@Volatile private var inFlight = 0
/** Guards [inFlight]: the encoder thread waits on it for the head unit's acks. */
private val ackLock = Object()
@@ -161,6 +168,8 @@ class GearslipRunner(
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
videoSource?.stop()
videoSource = null
stretcher?.release()
stretcher = null
CarAudio.openSink = null
audioLink?.close()
audioLink = null
@@ -800,13 +809,23 @@ class GearslipRunner(
// y=6, which is inside the cropped band, and the Uconnect's never passed 351 on a
// 480-high frame with a 130 margin. Without this every tap landed 120 px too high
// and the VW couldn't press past its first screen.
val shift = config?.marginInsets() ?: Insets.NONE
val shift = config?.let(::marginsFor) ?: Insets.NONE
// A stretched frame: the car's tap is on the frame, the UI is drawn smaller.
val drawn = drawSize
val points = pointers.mapIndexedNotNull { index, pointer ->
val fields = Wire.fields(pointer)
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
val id = Wire.varint(fields, 3)?.toInt() ?: index
TouchPoint(id, clamp(rawX + shift.left, size?.first), clamp(rawY + shift.top, size?.second))
if (drawn != null && size != null) {
TouchPoint(
id,
clamp(rawX * drawn.first / size.first, drawn.first),
clamp(rawY * drawn.second / size.second, drawn.second),
)
} else {
TouchPoint(id, clamp(rawX + shift.left, size?.first), clamp(rawY + shift.top, size?.second))
}
}
if (points.isEmpty()) return
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
@@ -979,6 +998,37 @@ class GearslipRunner(
return wide.minByOrNull(::pixels) ?: known.maxByOrNull(::pixels)
}
/**
* The part of a [frameWidth] x [frameHeight] frame shaped like the screen size set in
* Settings, centred and in even pixels; null when none is set or it is the frame's own shape.
* Some head units report the wrong size, then crop or squash the frame onto their real screen.
*/
private fun screenShape(frameWidth: Int, frameHeight: Int): Pair<Int, Int>? {
val screen = CarSettings.screenSize.value ?: return null
val wider = screen.width.toLong() * frameHeight >= screen.height.toLong() * frameWidth
val shape = if (wider) {
frameWidth to (frameWidth.toLong() * screen.height / screen.width).toInt()
} else {
(frameHeight.toLong() * screen.width / screen.height).toInt() to frameHeight
}
val even = (shape.first / 2 * 2).coerceAtLeast(2) to (shape.second / 2 * 2).coerceAtLeast(2)
return even.takeIf { it != (frameWidth to frameHeight) }
}
/**
* The strips of the frame the car doesn't show: the head unit's own margins, or, when a screen
* size is set to crop, whatever lies outside [screenShape]. The UI keeps out of them and taps
* are measured from inside them.
*/
private fun marginsFor(config: ServiceDiscovery.VideoConfig): Insets {
val (width, height) = config.pixelSize() ?: return config.marginInsets()
if (CarSettings.screenFit.value != ScreenFit.CROP) return config.marginInsets()
val (w, h) = screenShape(width, height) ?: return config.marginInsets()
val sideX = width - w
val sideY = height - h
return Insets(top = sideY / 2, bottom = sideY - sideY / 2, left = sideX / 2, right = sideX - sideX / 2)
}
private fun startVideoSource() {
lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe
val index = selectedConfigIndex ?: 0
@@ -999,8 +1049,21 @@ class GearslipRunner(
log.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
// The car crops its margins off the frame, so keep the UI out of them unless a vehicle
// profile has its own tuned insets.
if (vehicleProfile?.insets.let { it == null || it == Insets.NONE }) {
val insets = config.marginInsets()
stretcher?.release()
stretcher = null
drawSize = null
val shape = screenShape(width, height)
val squash = shape != null && CarSettings.screenFit.value == ScreenFit.SQUASH
if (shape != null) {
log.i(
"screen size set to ${CarSettings.screenSize.value} in Settings: " +
"${if (squash) "drawing" else "cropping to"} ${shape.first}x${shape.second} of the ${width}x$height frame",
)
}
if (squash) {
CarEnvironment.setInsets(Insets.NONE)
} else if (vehicleProfile?.insets.let { it == null || it == Insets.NONE }) {
val insets = marginsFor(config)
CarEnvironment.setInsets(insets)
if (insets != Insets.NONE) log.i("keeping the UI clear of the car's margins: $insets")
}
@@ -1027,7 +1090,16 @@ class GearslipRunner(
source.start()
val surface = source.inputSurface
if (projection != null && surface != null) {
projection.onSurfaceReady(surface, width, height, config.density)
val stretch = shape?.takeIf { squash }?.let { (w, h) ->
FrameStretcher(surface, w, h, width, height).takeIf { it.start() }
}
stretcher = stretch
if (stretch != null) {
drawSize = stretch.width to stretch.height
projection.onSurfaceReady(stretch.input, stretch.width, stretch.height, config.density)
} else {
projection.onSurfaceReady(surface, width, height, config.density)
}
SessionStatus.projecting()
BluetoothAudioCheck.onProjecting()
LocationCheck.onProjecting()
@@ -18,6 +18,30 @@ enum class Units { AUTO, IMPERIAL, METRIC }
/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */
enum class AppTheme { PHONE, LIGHT, DARK }
/** A screen's width and height in pixels, as someone typed it: "1280x480". */
data class ScreenSize(val width: Int, val height: Int) {
override fun toString() = "${width}x$height"
companion object {
fun parse(text: String?): ScreenSize? {
val parts = text?.lowercase()?.split('x', '×', ' ')?.filter { it.isNotBlank() } ?: return null
if (parts.size != 2) return null
val w = parts[0].trim().toIntOrNull() ?: return null
val h = parts[1].trim().toIntOrNull() ?: return null
return if (w in 100..8000 && h in 100..8000) ScreenSize(w, h) else null
}
}
}
/**
* How a head unit puts our frame on a screen of another shape. [CROP] cuts the edges off, as
* a car's own margins do; [SQUASH] shrinks the whole frame to fit, stretching everything.
*/
enum class ScreenFit { CROP, SQUASH }
/** What size the phone's car preview draws: the last car's, this device's whole screen, or one typed in. */
enum class PreviewSize { LAST_CAR, FILL, CUSTOM }
/** When the nav bar gets out of the way: never, during a route, or whenever the map is up. */
enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
@@ -49,6 +73,10 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
private const val KEY_UNITS = "car_units"
private const val KEY_NAV_BAR = "car_nav_bar"
private const val KEY_SCREEN_SIZE = "car_screen_size"
private const val KEY_SCREEN_FIT = "car_screen_fit"
private const val KEY_PREVIEW_SIZE = "preview_size"
private const val KEY_PREVIEW_CUSTOM = "preview_custom_size"
private const val KEY_WHATS_NEW = "car_whats_new_after_updates"
private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code"
@@ -88,6 +116,35 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
val units: StateFlow<Units> = unitsFlow
private val screenSizeFlow = MutableStateFlow<ScreenSize?>(null)
private val screenFitFlow = MutableStateFlow(ScreenFit.CROP)
private val previewSizeFlow = MutableStateFlow(PreviewSize.LAST_CAR)
private val previewCustomFlow = MutableStateFlow<ScreenSize?>(null)
/** The car screen's real size, when the head unit gets it wrong; null trusts the head unit. */
val screenSize: StateFlow<ScreenSize?> = screenSizeFlow
val screenFit: StateFlow<ScreenFit> = screenFitFlow
val previewSize: StateFlow<PreviewSize> = previewSizeFlow
val previewCustom: StateFlow<ScreenSize?> = previewCustomFlow
/** Takes effect the next time the car connects: the video size is agreed at the start. */
fun setScreenSize(size: ScreenSize?) {
screenSizeFlow.value = size
AppSettings.putString(app, KEY_SCREEN_SIZE, size?.toString().orEmpty())
}
fun setScreenFit(fit: ScreenFit) {
screenFitFlow.value = fit
AppSettings.putString(app, KEY_SCREEN_FIT, fit.name)
}
fun setPreviewSize(size: PreviewSize, custom: ScreenSize? = previewCustomFlow.value) {
previewSizeFlow.value = size
previewCustomFlow.value = custom
AppSettings.putString(app, KEY_PREVIEW_SIZE, size.name)
AppSettings.putString(app, KEY_PREVIEW_CUSTOM, custom?.toString().orEmpty())
}
private val navBarFlow = MutableStateFlow(NavBarMode.SHOW)
val navBarMode: StateFlow<NavBarMode> = navBarFlow
@@ -255,6 +312,14 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
unitsFlow.value = runCatching {
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
}.getOrDefault(Units.AUTO)
screenSizeFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_SCREEN_SIZE, ""))
screenFitFlow.value = runCatching {
ScreenFit.valueOf(AppSettings.getString(app, KEY_SCREEN_FIT, ScreenFit.CROP.name))
}.getOrDefault(ScreenFit.CROP)
previewSizeFlow.value = runCatching {
PreviewSize.valueOf(AppSettings.getString(app, KEY_PREVIEW_SIZE, PreviewSize.LAST_CAR.name))
}.getOrDefault(PreviewSize.LAST_CAR)
previewCustomFlow.value = ScreenSize.parse(AppSettings.getString(app, KEY_PREVIEW_CUSTOM, ""))
navBarFlow.value = runCatching {
NavBarMode.valueOf(AppSettings.getString(app, KEY_NAV_BAR, NavBarMode.SHOW.name))
}.getOrDefault(NavBarMode.SHOW)
@@ -52,6 +52,8 @@ fun CarSettingsScreen() {
val night by CarSettings.nightMode.collectAsState()
val units by CarSettings.units.collectAsState()
val navBar by CarSettings.navBarMode.collectAsState()
val screenSize by CarSettings.screenSize.collectAsState()
val screenFit by CarSettings.screenFit.collectAsState()
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
val pipeAudio by CarSettings.pipeAudio.collectAsState()
val speakDirections by CarSettings.speakDirections.collectAsState()
@@ -116,6 +118,22 @@ fun CarSettingsScreen() {
units,
) { CarSettings.setUnits(it) }
// For a head unit that reports the wrong size and stretches the picture (issue #12).
ChoiceRow(
"Screen size (next connection)",
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
.map { it to (it?.toString() ?: "From the car") },
screenSize,
) { CarSettings.setScreenSize(it) }
if (screenSize != null) {
ChoiceRow(
"Fit to the screen",
listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"),
screenFit,
) { CarSettings.setScreenFit(it) }
}
ChoiceRow(
"Nav bar",
listOf(
@@ -417,3 +435,8 @@ private fun StepButton(text: String, modifier: Modifier, onClick: () -> Unit) {
}
}
/** Common car screen sizes; Settings on the phone takes any other. */
private val SCREEN_PRESETS = listOf(
ScreenSize(800, 480), ScreenSize(1024, 600), ScreenSize(1280, 480), ScreenSize(1280, 720),
ScreenSize(1440, 540), ScreenSize(1920, 720), ScreenSize(1920, 1080),
)
@@ -75,9 +75,9 @@ fun CarPreviewScreen(onBack: () -> Unit) {
// 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 width = frame.width
val height = frame.height
val density = frame.densityDpi
val previewSize by app.seb3thehacker.gearslip.car.CarSettings.previewSize.collectAsStateWithLifecycle()
val custom by app.seb3thehacker.gearslip.car.CarSettings.previewCustom.collectAsStateWithLifecycle()
val (width, height, density) = previewFrame(context, frame, previewSize, custom)
val projector = remember { ScreenProjector(context) }
@@ -261,3 +261,27 @@ private fun android.media.Image.toBitmap(width: Int, height: Int): Bitmap {
return if (padded.width == width) padded
else Bitmap.createBitmap(padded, 0, 0, width, height).also { padded.recycle() }
}
/**
* The size the preview draws at. The last car's frame by default; this device's whole screen,
* for a head unit running Gearslip on its own (issue #12); or a size typed into Settings. The
* two new ones get a car-like density: 160dpi for every 480 pixels of height.
*/
private fun previewFrame(
context: android.content.Context,
lastCar: CarEnvironment.Frame,
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))
return when (size) {
app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM ->
custom?.let { carLike(it.width, it.height) } ?: Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
app.seb3thehacker.gearslip.car.PreviewSize.FILL -> {
val bounds = context.getSystemService(android.view.WindowManager::class.java).maximumWindowMetrics.bounds
// Landscape, whichever way the device is held right now.
carLike(maxOf(bounds.width(), bounds.height()) / 2 * 2, minOf(bounds.width(), bounds.height()) / 2 * 2)
}
}
}
@@ -0,0 +1,158 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.PreviewSize
import app.seb3thehacker.gearslip.car.ScreenFit
import app.seb3thehacker.gearslip.car.ScreenSize
/**
* The car screen's real size, for a head unit that reports the wrong one and stretches the
* picture, and the size the phone's car preview draws at. The same screen size and fit are in
* the car's own Settings; typing in any size is only here.
*/
@Composable
internal fun CarScreenSection() {
val size by CarSettings.screenSize.collectAsState()
val fit by CarSettings.screenFit.collectAsState()
val preview by CarSettings.previewSize.collectAsState()
val previewCustom by CarSettings.previewCustom.collectAsState()
var editingCar by remember { mutableStateOf(false) }
var editingPreview by remember { mutableStateOf(false) }
if (editingCar) {
SizeDialog(
title = "Car screen size",
note = "Use this if the car's picture looks stretched. Enter the screen's real size, " +
"such as 1280x480. It takes effect the next time you connect.",
initial = size,
clearLabel = "Use the car's",
onDismiss = { editingCar = false },
onSave = { CarSettings.setScreenSize(it); editingCar = false },
)
}
if (editingPreview) {
SizeDialog(
title = "Preview size",
note = "The size the car preview draws at, such as 1920x720.",
initial = previewCustom,
clearLabel = null,
onDismiss = { editingPreview = false },
onSave = { custom ->
CarSettings.setPreviewSize(if (custom == null) PreviewSize.LAST_CAR else PreviewSize.CUSTOM, custom)
editingPreview = false
},
)
}
SettingsSection(
"Car screen",
footer = "Gearslip uses the size the car reports unless you set one.",
) {
SettingsRow(
"Screen size",
Modifier.clickable { editingCar = true },
subtitle = size?.let { "$it, set by you" } ?: "From the car",
)
if (size != null) {
SettingsDivider()
SettingsRow(
"Crop the edges",
Modifier.selectable(fit == ScreenFit.CROP, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.CROP) },
subtitle = "For a car that cuts off the top and bottom, or the sides",
trailing = { RadioButton(selected = fit == ScreenFit.CROP, onClick = null) },
)
SettingsDivider()
SettingsRow(
"Squash to fit",
Modifier.selectable(fit == ScreenFit.SQUASH, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.SQUASH) },
subtitle = "For a car that squeezes the whole picture, so circles look like ovals",
trailing = { RadioButton(selected = fit == ScreenFit.SQUASH, onClick = null) },
)
}
}
SettingsSection("Car preview size") {
SettingsRow(
"Last car",
Modifier.selectable(preview == PreviewSize.LAST_CAR, role = Role.RadioButton) {
CarSettings.setPreviewSize(PreviewSize.LAST_CAR)
},
subtitle = "The size of the last car you connected to",
trailing = { RadioButton(selected = preview == PreviewSize.LAST_CAR, onClick = null) },
)
SettingsDivider()
SettingsRow(
"This screen",
Modifier.selectable(preview == PreviewSize.FILL, role = Role.RadioButton) {
CarSettings.setPreviewSize(PreviewSize.FILL)
},
subtitle = "Fills this device's screen, for a head unit running Gearslip itself",
trailing = { RadioButton(selected = preview == PreviewSize.FILL, onClick = null) },
)
SettingsDivider()
SettingsRow(
"Custom",
Modifier.selectable(preview == PreviewSize.CUSTOM, role = Role.RadioButton) {
if (previewCustom == null) editingPreview = true else CarSettings.setPreviewSize(PreviewSize.CUSTOM)
},
subtitle = previewCustom?.toString() ?: "Enter a size",
trailing = { RadioButton(selected = preview == PreviewSize.CUSTOM, onClick = null) },
)
if (preview == PreviewSize.CUSTOM) {
SettingsDivider()
SettingsRow("Change the custom size", Modifier.clickable { editingPreview = true })
}
}
}
/** Asks for a width and height; [clearLabel], when given, offers going back to no size at all. */
@Composable
private fun SizeDialog(
title: String,
note: String,
initial: ScreenSize?,
clearLabel: String?,
onDismiss: () -> Unit,
onSave: (ScreenSize?) -> Unit,
) {
var width by remember { mutableStateOf(initial?.width?.toString().orEmpty()) }
var height by remember { mutableStateOf(initial?.height?.toString().orEmpty()) }
val parsed = ScreenSize.parse("${width}x$height")
val number = KeyboardOptions(keyboardType = KeyboardType.Number)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(note)
OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text("Width") }, singleLine = true, keyboardOptions = number)
OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text("Height") }, singleLine = true, keyboardOptions = number)
}
},
confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text("Save") } },
dismissButton = {
if (clearLabel != null) TextButton(onClick = { onSave(null) }) { Text(clearLabel) }
else TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@@ -126,6 +126,7 @@ fun SettingsScreen(
ConnectionSection()
HelpSection(onOpenHelp, onOpenCarPreview, onOpenLogs)
CarAudioSection()
CarScreenSection()
VoiceModelSection()
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
SettingsSection(