diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/FrameStretcher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/FrameStretcher.kt new file mode 100644 index 0000000..81044f6 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/FrameStretcher.kt @@ -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(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); + } + """ + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index 633fb38..e4741d6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -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? = 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? { + 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() 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 f0192a9..5b31c8c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -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 = unitsFlow + private val screenSizeFlow = MutableStateFlow(null) + private val screenFitFlow = MutableStateFlow(ScreenFit.CROP) + private val previewSizeFlow = MutableStateFlow(PreviewSize.LAST_CAR) + private val previewCustomFlow = MutableStateFlow(null) + + /** The car screen's real size, when the head unit gets it wrong; null trusts the head unit. */ + val screenSize: StateFlow = screenSizeFlow + val screenFit: StateFlow = screenFitFlow + val previewSize: StateFlow = previewSizeFlow + val previewCustom: StateFlow = 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 = 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) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt index d65412b..7d63c3c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -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(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), +) 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 f7c3a88..6a4e68c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarPreviewScreen.kt @@ -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 { + 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) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarScreenSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarScreenSettings.kt new file mode 100644 index 0000000..7f48c80 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarScreenSettings.kt @@ -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") } + }, + ) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index bee694f..db61df6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -126,6 +126,7 @@ fun SettingsScreen( ConnectionSection() HelpSection(onOpenHelp, onOpenCarPreview, onOpenLogs) CarAudioSection() + CarScreenSection() VoiceModelSection() if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps) SettingsSection(