mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
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:
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.CarAssistant
|
||||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||||
import app.seb3thehacker.gearslip.car.CarKeys
|
import app.seb3thehacker.gearslip.car.CarKeys
|
||||||
|
import app.seb3thehacker.gearslip.car.ScreenFit
|
||||||
import app.seb3thehacker.gearslip.car.CarSensors
|
import app.seb3thehacker.gearslip.car.CarSensors
|
||||||
import app.seb3thehacker.gearslip.car.CarServices
|
import app.seb3thehacker.gearslip.car.CarServices
|
||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
@@ -72,6 +73,12 @@ class GearslipRunner(
|
|||||||
private var vehicleProfile: VehicleProfile? = null
|
private var vehicleProfile: VehicleProfile? = null
|
||||||
@Volatile private var videoStarted = false
|
@Volatile private var videoStarted = false
|
||||||
private var videoSource: VideoSource? = null
|
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
|
@Volatile private var inFlight = 0
|
||||||
/** Guards [inFlight]: the encoder thread waits on it for the head unit's acks. */
|
/** Guards [inFlight]: the encoder thread waits on it for the head unit's acks. */
|
||||||
private val ackLock = Object()
|
private val ackLock = Object()
|
||||||
@@ -161,6 +168,8 @@ class GearslipRunner(
|
|||||||
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
|
synchronized(ackLock) { ackLock.notifyAll() } // free an encoder waiting on an ack
|
||||||
videoSource?.stop()
|
videoSource?.stop()
|
||||||
videoSource = null
|
videoSource = null
|
||||||
|
stretcher?.release()
|
||||||
|
stretcher = null
|
||||||
CarAudio.openSink = null
|
CarAudio.openSink = null
|
||||||
audioLink?.close()
|
audioLink?.close()
|
||||||
audioLink = null
|
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
|
// 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
|
// 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.
|
// 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 points = pointers.mapIndexedNotNull { index, pointer ->
|
||||||
val fields = Wire.fields(pointer)
|
val fields = Wire.fields(pointer)
|
||||||
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
|
val rawX = Wire.varint(fields, 1)?.toInt() ?: return@mapIndexedNotNull null
|
||||||
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
|
val rawY = Wire.varint(fields, 2)?.toInt() ?: return@mapIndexedNotNull null
|
||||||
val id = Wire.varint(fields, 3)?.toInt() ?: index
|
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
|
if (points.isEmpty()) return
|
||||||
val actionIndex = (Wire.varint(touchFields, 2)?.toInt() ?: 0).coerceIn(0, points.size - 1)
|
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)
|
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() {
|
private fun startVideoSource() {
|
||||||
lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe
|
lastKeyframeSentAt = System.currentTimeMillis() // frame #1 is always a keyframe
|
||||||
val index = selectedConfigIndex ?: 0
|
val index = selectedConfigIndex ?: 0
|
||||||
@@ -999,8 +1049,21 @@ class GearslipRunner(
|
|||||||
log.i("-> Start(session_id=$SESSION_ID, configuration_index=$index -> $config)")
|
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
|
// The car crops its margins off the frame, so keep the UI out of them unless a vehicle
|
||||||
// profile has its own tuned insets.
|
// profile has its own tuned insets.
|
||||||
if (vehicleProfile?.insets.let { it == null || it == Insets.NONE }) {
|
stretcher?.release()
|
||||||
val insets = config.marginInsets()
|
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)
|
CarEnvironment.setInsets(insets)
|
||||||
if (insets != Insets.NONE) log.i("keeping the UI clear of the car's margins: $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()
|
source.start()
|
||||||
val surface = source.inputSurface
|
val surface = source.inputSurface
|
||||||
if (projection != null && surface != null) {
|
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()
|
SessionStatus.projecting()
|
||||||
BluetoothAudioCheck.onProjecting()
|
BluetoothAudioCheck.onProjecting()
|
||||||
LocationCheck.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). */
|
/** The car UI's own colours. Separate from [NightMode], which is about the light outside (the map). */
|
||||||
enum class AppTheme { PHONE, LIGHT, DARK }
|
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. */
|
/** 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 }
|
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_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
||||||
private const val KEY_UNITS = "car_units"
|
private const val KEY_UNITS = "car_units"
|
||||||
private const val KEY_NAV_BAR = "car_nav_bar"
|
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 = "car_whats_new_after_updates"
|
||||||
private const val KEY_WHATS_NEW_SEEN = "car_whats_new_seen_code"
|
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
|
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)
|
private val navBarFlow = MutableStateFlow(NavBarMode.SHOW)
|
||||||
val navBarMode: StateFlow<NavBarMode> = navBarFlow
|
val navBarMode: StateFlow<NavBarMode> = navBarFlow
|
||||||
|
|
||||||
@@ -255,6 +312,14 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
unitsFlow.value = runCatching {
|
unitsFlow.value = runCatching {
|
||||||
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
Units.valueOf(AppSettings.getString(app, KEY_UNITS, Units.AUTO.name))
|
||||||
}.getOrDefault(Units.AUTO)
|
}.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 {
|
navBarFlow.value = runCatching {
|
||||||
NavBarMode.valueOf(AppSettings.getString(app, KEY_NAV_BAR, NavBarMode.SHOW.name))
|
NavBarMode.valueOf(AppSettings.getString(app, KEY_NAV_BAR, NavBarMode.SHOW.name))
|
||||||
}.getOrDefault(NavBarMode.SHOW)
|
}.getOrDefault(NavBarMode.SHOW)
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ fun CarSettingsScreen() {
|
|||||||
val night by CarSettings.nightMode.collectAsState()
|
val night by CarSettings.nightMode.collectAsState()
|
||||||
val units by CarSettings.units.collectAsState()
|
val units by CarSettings.units.collectAsState()
|
||||||
val navBar by CarSettings.navBarMode.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 whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
||||||
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||||
val speakDirections by CarSettings.speakDirections.collectAsState()
|
val speakDirections by CarSettings.speakDirections.collectAsState()
|
||||||
@@ -116,6 +118,22 @@ fun CarSettingsScreen() {
|
|||||||
units,
|
units,
|
||||||
) { CarSettings.setUnits(it) }
|
) { 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(
|
ChoiceRow(
|
||||||
"Nav bar",
|
"Nav bar",
|
||||||
listOf(
|
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.
|
// 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
|
||||||
|
|
||||||
val width = frame.width
|
val previewSize by app.seb3thehacker.gearslip.car.CarSettings.previewSize.collectAsStateWithLifecycle()
|
||||||
val height = frame.height
|
val custom by app.seb3thehacker.gearslip.car.CarSettings.previewCustom.collectAsStateWithLifecycle()
|
||||||
val density = frame.densityDpi
|
val (width, height, density) = previewFrame(context, frame, previewSize, custom)
|
||||||
|
|
||||||
val projector = remember { ScreenProjector(context) }
|
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
|
return if (padded.width == width) padded
|
||||||
else Bitmap.createBitmap(padded, 0, 0, width, height).also { padded.recycle() }
|
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()
|
ConnectionSection()
|
||||||
HelpSection(onOpenHelp, onOpenCarPreview, onOpenLogs)
|
HelpSection(onOpenHelp, onOpenCarPreview, onOpenLogs)
|
||||||
CarAudioSection()
|
CarAudioSection()
|
||||||
|
CarScreenSection()
|
||||||
VoiceModelSection()
|
VoiceModelSection()
|
||||||
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
|
|||||||
Reference in new issue
Block a user