Integrate current PR marker controls while preserving the DiLink 5.1 stream

This commit is contained in:
shihabal3amri committed 2026-09-27 21:51:35 +04:00
commit 9edcf527ce
7 files changed
+180 -76

No files matched your search

@@ -53,8 +53,9 @@ object AirPlayPersistence {
private const val KEY_FPS = "display_fps"
private const val KEY_MEDIA_BUFFER_MS = "media_buffer_ms"
private const val KEY_CLUSTER_MAP = "cluster_map_enabled"
private const val KEY_CLUSTER_MAP_LIFT = "cluster_map_lift_percent"
private const val KEY_CLUSTER_MAP_SHIFT = "cluster_map_shift_left_percent"
private const val KEY_CLUSTER_MAP_SCALE = "cluster_map_scale_percent"
private const val KEY_CLUSTER_MARKER_X = "cluster_marker_horizontal_step"
private const val KEY_CLUSTER_MARKER_Y = "cluster_marker_vertical_step"
private const val KEY_WIDTH_PHYSICAL_MM = "display_width_physical_mm"
private const val KEY_PHYSICAL_SIZE_BASIS = "display_physical_size_basis"
private const val KEY_MAX_DETECTED_WIDTH = "display_max_detected_width"
@@ -410,24 +411,32 @@ object AirPlayPersistence {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_CLUSTER_MAP, enabled).apply()
}
fun loadClusterMapLiftPercent(context: Context): Int = CarPlayClusterDisplay.DEFAULT_LIFT_PERCENT.let { default ->
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_CLUSTER_MAP_LIFT, default)
.takeIf { it in CarPlayClusterDisplay.liftPresets } ?: default
fun loadClusterMapScalePercent(context: Context): Int = CarPlayClusterDisplay.STREAM_SCALE_PERCENT.let { default ->
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_CLUSTER_MAP_SCALE, default)
.takeIf { it in CarPlayClusterDisplay.scalePresets } ?: default
}
fun saveClusterMapLiftPercent(context: Context, percent: Int) {
if (percent !in CarPlayClusterDisplay.liftPresets) return
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putInt(KEY_CLUSTER_MAP_LIFT, percent).apply()
fun saveClusterMapScalePercent(context: Context, percent: Int) {
if (percent !in CarPlayClusterDisplay.scalePresets) return
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putInt(KEY_CLUSTER_MAP_SCALE, percent).apply()
}
fun loadClusterMapShiftPercent(context: Context): Int = CarPlayClusterDisplay.DEFAULT_SHIFT_PERCENT.let { default ->
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_CLUSTER_MAP_SHIFT, default)
.takeIf { it in CarPlayClusterDisplay.shiftPresets } ?: default
fun loadClusterMarkerHorizontalStep(context: Context): Int =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_CLUSTER_MARKER_X, 0)
.coerceIn(CarPlayClusterDisplay.horizontalSteps)
fun saveClusterMarkerHorizontalStep(context: Context, step: Int) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putInt(KEY_CLUSTER_MARKER_X, step.coerceIn(CarPlayClusterDisplay.horizontalSteps)).apply()
}
fun saveClusterMapShiftPercent(context: Context, percent: Int) {
if (percent !in CarPlayClusterDisplay.shiftPresets) return
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putInt(KEY_CLUSTER_MAP_SHIFT, percent).apply()
fun loadClusterMarkerVerticalStep(context: Context): Int =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getInt(KEY_CLUSTER_MARKER_Y, 0)
.coerceIn(CarPlayClusterDisplay.verticalSteps)
fun saveClusterMarkerVerticalStep(context: Context, step: Int) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putInt(KEY_CLUSTER_MARKER_Y, step.coerceIn(CarPlayClusterDisplay.verticalSteps)).apply()
}
fun loadRightHandDrive(context: Context): Boolean =
@@ -687,9 +687,12 @@ class CarPlayHostActivity : ComponentActivity() {
return CarPlayClusterDisplay.config(
size.x,
size.y,
AirPlayPersistence.loadClusterMapLiftPercent(this),
AirPlayPersistence.loadClusterMapShiftPercent(this),
).also { appendLog("Cluster map: requesting ${size.x}x${size.y} cluster screen safeArea=${it.safeArea}") }
AirPlayPersistence.loadClusterMapScalePercent(this),
AirPlayPersistence.loadClusterMarkerHorizontalStep(this),
AirPlayPersistence.loadClusterMarkerVerticalStep(this),
).also {
appendLog("Cluster map: requesting ${it.widthPixels}x${it.heightPixels} on ${size.x}x${size.y} safeArea=${it.safeArea}")
}
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
@@ -27,7 +27,11 @@ internal object DiLink51ClusterLayout {
}
const val STREAM_WIDTH = 1920
const val STREAM_HEIGHT = 720
fun streamConfig(): AirPlayDisplayConfig = CarPlayClusterDisplay.config(STREAM_WIDTH, STREAM_HEIGHT, 0, 0)
// DiLink 5.1 retains its measured native-size stream and bottom-centered crop.
// DiLink 5's marker safe area and scale controls must not alter this profile.
fun streamConfig(): AirPlayDisplayConfig = CarPlayClusterDisplay.config(
STREAM_WIDTH, STREAM_HEIGHT, scalePercent = 100,
).copy(safeArea = null)
fun supported(fingerprint: String = Build.FINGERPRINT): Boolean = fingerprint == FINGERPRINT
fun theme(context: Context): Theme = Theme.entries.firstOrNull {
@@ -234,7 +234,7 @@ class DiPlayActivity : ComponentActivity() {
"Shows the iPhone's cluster map on the instrument cluster. Choose Small or Full screen navi in the cluster's steering-wheel menu.",
AirPlayPersistence.loadClusterMapEnabled(this)) {
AirPlayPersistence.saveClusterMapEnabled(this, it)
reconnectForClusterMap(force = true)
reconnectForClusterMap()
}
if (DiLink51ClusterLayout.supported()) {
val automatic = DiLink51ClusterLayout.automatic(this)
@@ -270,19 +270,27 @@ class DiPlayActivity : ComponentActivity() {
reconnectForClusterMap()
}
} else {
val lifts = CarPlayClusterDisplay.liftPresets
choice(card, "Car position on cluster map", listOf("Bottom", "Higher · default", "Much higher"),
lifts.indexOf(AirPlayPersistence.loadClusterMapLiftPercent(this)).coerceAtLeast(0)) {
AirPlayPersistence.saveClusterMapLiftPercent(this, lifts[it])
reconnectForClusterMap()
val sizes = CarPlayClusterDisplay.scalePresets
choice(card, "Cluster map size", listOf("Standard · sharpest", "Larger · default", "Largest"),
sizes.indexOf(AirPlayPersistence.loadClusterMapScalePercent(this)).coerceAtLeast(0)) {
AirPlayPersistence.saveClusterMapScalePercent(this, sizes[it])
}
val shifts = CarPlayClusterDisplay.shiftPresets
choice(card, "Car position across cluster map",
listOf("Center", "Left · by 1/12", "Left · by 1/10", "Left · by 1/9 · default", "Left · by 1/8", "Left · by 1/6", "Far left · by 1/3"),
shifts.indexOf(AirPlayPersistence.loadClusterMapShiftPercent(this)).coerceAtLeast(0)) {
AirPlayPersistence.saveClusterMapShiftPercent(this, shifts[it])
reconnectForClusterMap()
val across = CarPlayClusterDisplay.horizontalSteps.toList()
choice(card, "Car marker · horizontal", across.map { markerStepLabel(it, "Left", "Right") },
across.indexOf(AirPlayPersistence.loadClusterMarkerHorizontalStep(this)).coerceAtLeast(0)) {
AirPlayPersistence.saveClusterMarkerHorizontalStep(this, across[it])
}
val upDown = CarPlayClusterDisplay.verticalSteps.toList()
choice(card, "Car marker · vertical", upDown.map { markerStepLabel(it, "Up", "Down") },
upDown.indexOf(AirPlayPersistence.loadClusterMarkerVerticalStep(this)).coerceAtLeast(0)) {
AirPlayPersistence.saveClusterMarkerVerticalStep(this, upDown[it])
}
card.addView(button("Reset car marker to centre", false) {
AirPlayPersistence.saveClusterMarkerHorizontalStep(this, 0)
AirPlayPersistence.saveClusterMarkerVerticalStep(this, 0)
render()
reconnectForClusterMap()
}, matchButton(10, 56))
}
}
}
@@ -437,9 +445,17 @@ class DiPlayActivity : ComponentActivity() {
}
}
// The cluster screen is described at connection time, so a running session reconnects to apply.
private fun reconnectForClusterMap(force: Boolean = false) {
if ((force || AirPlayPersistence.loadClusterMapEnabled(this)) && CarPlayBackgroundSession.hasSession()) connect(AirPlayPersistence.loadWirelessEnabled(this))
// "Left 20 %", "Centre · default", "Down 10 %": a signed step reads as a direction and a distance.
private fun markerStepLabel(step: Int, negative: String, positive: String): String = when {
step == 0 -> "Centre · default"
step < 0 -> "$negative ${-step * CarPlayClusterDisplay.MARKER_STEP_PERCENT} %"
else -> "$positive ${step * CarPlayClusterDisplay.MARKER_STEP_PERCENT} %"
}
// The cluster screen is described at connection time, so a running session reconnects over
// its current link. The position choices need no call: "Apply and reconnect" already does it.
private fun reconnectForClusterMap() {
if (CarPlayBackgroundSession.hasSession()) connect(AirPlayPersistence.loadWirelessEnabled(this))
}
private fun applyWirelessLink(mode: WirelessHotspotMode) {
+2 -1
View File
@@ -21,7 +21,8 @@ Validated on DiLink5.0 / Android12, firmware `BYD-AUTO/DiLink5.0/DiLink5.0:12/SK
- Turn on "CarPlay map on instrument cluster" under BYD navigation. The switch appears only when a cluster projection display is visible to the app.
- In the cluster's own steering-wheel menu, choose "Full screen navi" or "Small screen navi". "Turn on by navi" shows the map only during stock BYD navigation, so it keeps showing the arrow output instead.
- The cluster's speed readout stays visible. "Small screen navi" crops the same picture on the cluster side; Android does not report that crop.
- "Car position on cluster map" and "Car position across cluster map" set CarPlay's safe area, which moves the car marker. The defaults, Higher and 1/9 to the left, fit the tested cluster.
- The car marker is placed through CarPlay's safe area: the centre of the panel (x 35–64 %, y 16–75 %), measured with a calibration grid to be clear of BYD's own readouts in both Full and Small screen navi. "Car marker · horizontal" (Left 40 % … Right 40 %) and "Car marker · vertical" (Up 30 % … Down 30 %) move it from there in 10 % steps of the panel. Near a panel edge the safe area shrinks so the marker stays at its centre.
- "Cluster map size" sets the stream size, which the cluster scales up to the panel: Standard (100 %, sharpest), Larger (83 %, 1600x600, default) or Largest (67 %). Apple Maps ignores the reported physical size on the cluster, so resolution is the only way to change the map's scale.
How it works: the iPhone lists the cluster content it offers in its `/info` request (`altScreenURLs`). DiPlay declares a second display of the cluster's size with no input devices and `initialURL=maps:/car/instrumentcluster/map`; without an initial URL the iPhone streams only a black frame. BYD exposes the cluster projection area as public presentation displays owned by `com.byd.containerservice`. The stock map's display (`fission_bg_XDJAScreenProjection`) is hidden from third-party apps, but its `shared_…_0` sibling is composited on top of it, so DiPlay shows a `Presentation` there.
@@ -5,37 +5,86 @@ package com.shilapi.xcertplay.airplay
*
* The iPhone lists the cluster content it offers in its /info request (`altScreenURLs`); without
* an initial URL it streams a black frame. The cluster has no input. Apple Maps keeps the car
* position inside the safe area while the map still fills the panel, so bottom and right insets
* move the car up and to the left, away from the cluster's own readouts.
* position inside the safe area while the map still fills the panel, so the safe area is set to
* the part of the panel the cluster leaves uncovered.
*/
object CarPlayClusterDisplay {
const val MAP_URL = "maps:/car/instrumentcluster/map"
/** Bottom inset in percent of the height. The default suits the BYD cluster. */
const val DEFAULT_LIFT_PERCENT = 25
val liftPresets = listOf(0, DEFAULT_LIFT_PERCENT, 45)
/**
* Where the car marker goes, as percent of the panel (left, top, right, bottom). Measured on a
* DiLink 5.0 cluster with a calibration grid. In "Full screen navi" BYD draws a status row,
* turn/ADAS icons, the ADAS lane view (from x 68 %), speed and power readouts and a bottom band
* over the map; "Small screen navi" crops the panel to a centre window of about x 31–68 %,
* y 16–89 %. This centre area is clear in both modes, so no per-mode setting is needed.
*/
val SAFE_AREA_PERCENT = AirPlayInsets(top = 16, bottom = 25, left = 35, right = 36)
/** Right inset in percent of the width; the car moves left by half of it (22 % ≈ 1/9). */
const val DEFAULT_SHIFT_PERCENT = 22
val shiftPresets = listOf(0, 17, 20, DEFAULT_SHIFT_PERCENT, 25, 33, 67)
/** The driver can move the marker from that centre, in steps of 10 % of the panel. */
const val MARKER_STEP_PERCENT = 10
val horizontalSteps = -4..4 // negative = left
val verticalSteps = -3..3 // negative = up
private const val WIDTH_PHYSICAL_MM = 292 // a 12.3-inch 8:3 cluster panel
/** Where the car marker lands, in percent of the panel (x from the left, y from the top). */
fun markerPercent(horizontalStep: Int, verticalStep: Int): Pair<Double, Double> {
val area = SAFE_AREA_PERCENT
val x = (area.left + 100 - area.right) / 2.0 + horizontalStep.coerceIn(horizontalSteps) * MARKER_STEP_PERCENT
val y = (area.top + 100 - area.bottom) / 2.0 + verticalStep.coerceIn(verticalSteps) * MARKER_STEP_PERCENT
return x.coerceIn(MARKER_MARGIN, 100 - MARKER_MARGIN) to y.coerceIn(MARKER_MARGIN, 100 - MARKER_MARGIN)
}
private const val MARKER_MARGIN = 5.0
/**
* Stream size in percent of the panel. The cluster scales the stream up, so a smaller stream
* gives a larger map: 83 % is clearly larger and still sharp; 50 % was visibly blurry.
*/
const val STREAM_SCALE_PERCENT = 83
val scalePresets = listOf(100, STREAM_SCALE_PERCENT, 67)
private const val WIDTH_PHYSICAL_MM = 292 // a 12.3-inch 8:3 cluster panel; Apple Maps ignores it here
private const val FPS = 30
fun config(widthPixels: Int, heightPixels: Int, liftPercent: Int, shiftPercent: Int): AirPlayDisplayConfig {
val lift = heightPixels * liftPercent.coerceIn(0, 90) / 100
val shift = widthPixels * shiftPercent.coerceIn(0, 90) / 100
fun config(
widthPixels: Int,
heightPixels: Int,
scalePercent: Int = STREAM_SCALE_PERCENT,
horizontalStep: Int = 0,
verticalStep: Int = 0,
): AirPlayDisplayConfig {
// Height rounds to a multiple of 8 and width follows it, so the panel's aspect is kept
// (83 % of 1920x720 gives exactly 1600x600). The cluster scales the stream to the panel.
val scale = scalePercent.coerceIn(25, 100)
val height = roundTo8(heightPixels * scale / 100.0)
val width = roundTo8(height * widthPixels.toDouble() / heightPixels)
return AirPlayDisplayConfig(
widthPixels = widthPixels,
heightPixels = heightPixels,
widthPixels = width,
heightPixels = height,
widthPhysicalMm = WIDTH_PHYSICAL_MM,
heightPhysicalMm = Math.round(WIDTH_PHYSICAL_MM * heightPixels.toDouble() / widthPixels).toInt(),
heightPhysicalMm = Math.round(WIDTH_PHYSICAL_MM * height.toDouble() / width).toInt(),
fps = FPS,
primaryInputDevice = 0,
features = 0,
initialUrl = MAP_URL,
safeArea = if (lift > 0 || shift > 0) AirPlayInsets(bottom = lift, right = shift) else null,
safeArea = safeArea(width, height, horizontalStep, verticalStep),
safeAreaDrawOutside = true,
)
}
// The safe area keeps its measured size around the marker and shrinks only where the marker
// comes close to a panel edge, so the marker always sits at its centre.
private fun safeArea(width: Int, height: Int, horizontalStep: Int, verticalStep: Int): AirPlayInsets {
val area = SAFE_AREA_PERCENT
val (x, y) = markerPercent(horizontalStep, verticalStep)
val halfWidth = minOf((100 - area.left - area.right) / 2.0, x, 100 - x)
val halfHeight = minOf((100 - area.top - area.bottom) / 2.0, y, 100 - y)
return AirPlayInsets(
top = (height * (y - halfHeight) / 100).toInt(),
bottom = (height * (100 - y - halfHeight) / 100).toInt(),
left = (width * (x - halfWidth) / 100).toInt(),
right = (width * (100 - x - halfWidth) / 100).toInt(),
)
}
private fun roundTo8(value: Double): Int = (Math.round(value / 8) * 8).toInt().coerceAtLeast(8)
}
@@ -1,19 +1,12 @@
package com.shilapi.xcertplay.airplay
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CarPlayClusterDisplayTest {
@Test
fun defaultClusterIsAdvertisedAsAnInputlessMapScreen() {
val cluster = CarPlayClusterDisplay.config(
1920,
720,
CarPlayClusterDisplay.DEFAULT_LIFT_PERCENT,
CarPlayClusterDisplay.DEFAULT_SHIFT_PERCENT,
)
val info = AirPlayInfoPlist.build(
AirPlayConfig(
deviceName = "test",
@@ -21,7 +14,7 @@ class CarPlayClusterDisplayTest {
btMac = "02:00:00:00:00:02",
sourceVersion = "366.0",
main = AirPlayDisplayConfig(widthPixels = 1280, heightPixels = 720),
cluster = cluster,
cluster = CarPlayClusterDisplay.config(1920, 720),
),
)
@@ -29,8 +22,9 @@ class CarPlayClusterDisplayTest {
assertEquals(2, displays.size)
val alt = displays[1] as Map<*, *>
assertEquals(111, alt["type"])
assertEquals(1920, alt["widthPixels"])
assertEquals(720, alt["heightPixels"])
// 83 % of the 1920x720 panel, same 8:3 aspect.
assertEquals(1600, alt["widthPixels"])
assertEquals(600, alt["heightPixels"])
assertEquals(0, alt["features"])
assertEquals(0, alt["primaryInputDevice"])
assertEquals("maps:/car/instrumentcluster/map", alt["initialURL"])
@@ -38,14 +32,14 @@ class CarPlayClusterDisplayTest {
assertEquals(110, alt["heightPhysical"])
val view = (alt["viewAreas"] as List<*>).single() as Map<*, *>
assertEquals(1920, view["widthPixels"])
assertEquals(720, view["heightPixels"])
assertEquals(1600, view["widthPixels"])
assertEquals(600, view["heightPixels"])
val safe = view["safeArea"] as Map<*, *>
// 25 % off the bottom and 22 % off the right: the car sits higher and about 1/9 further left.
assertEquals(1498, safe["widthPixels"])
assertEquals(540, safe["heightPixels"])
assertEquals(0, safe["originXPixels"])
assertEquals(0, safe["originYPixels"])
// The centre area clear in both cluster navi modes: left 35 %, right 36 %, top 16 %, bottom 25 %.
assertEquals(560, safe["originXPixels"])
assertEquals(96, safe["originYPixels"])
assertEquals(464, safe["widthPixels"])
assertEquals(354, safe["heightPixels"])
assertEquals(true, safe["drawUIOutsideSafeArea"])
// The main screen keeps its touch and knob features.
@@ -53,22 +47,50 @@ class CarPlayClusterDisplayTest {
}
@Test
fun noOffsetsLeaveTheWholeClusterAsTheSafeArea() {
val cluster = CarPlayClusterDisplay.config(1920, 720, 0, 0)
fun scaledStreamsKeepThePanelAspect() {
val sizes = (CarPlayClusterDisplay.scalePresets + 50).map {
CarPlayClusterDisplay.config(1920, 720, scalePercent = it).let { c -> c.widthPixels to c.heightPixels }
}
assertNull(cluster.safeArea)
assertEquals(listOf(1920 to 720, 1600 to 600, 1280 to 480, 960 to 360), sizes)
assertTrue(CarPlayClusterDisplay.STREAM_SCALE_PERCENT in CarPlayClusterDisplay.scalePresets)
}
@Test
fun offsetsAreBoundedSoTheSafeAreaNeverCollapses() {
val cluster = CarPlayClusterDisplay.config(1000, 400, 150, -20)
fun markerStepsMoveTheWholeSafeArea() {
val centre = CarPlayClusterDisplay.config(1920, 720, scalePercent = 100).safeArea!!
val farLeftHigher = CarPlayClusterDisplay.config(1920, 720, scalePercent = 100, horizontalStep = -2, verticalStep = -1).safeArea!!
assertEquals(AirPlayInsets(bottom = 360, right = 0), cluster.safeArea)
// Two 10 % steps left (384 px) and one 10 % step up (72 px), same size as the centred area.
assertEquals(centre.left - 384, farLeftHigher.left)
assertEquals(centre.right + 384, farLeftHigher.right)
assertEquals(centre.top - 72, farLeftHigher.top)
assertEquals(centre.bottom + 72, farLeftHigher.bottom)
}
@Test
fun defaultsAreAmongTheOfferedChoices() {
assertTrue(CarPlayClusterDisplay.DEFAULT_LIFT_PERCENT in CarPlayClusterDisplay.liftPresets)
assertTrue(CarPlayClusterDisplay.DEFAULT_SHIFT_PERCENT in CarPlayClusterDisplay.shiftPresets)
fun markerStepsAreBounded() {
val beyond = CarPlayClusterDisplay.config(1920, 720, scalePercent = 100, horizontalStep = 9, verticalStep = -9).safeArea
val limit = CarPlayClusterDisplay.config(1920, 720, scalePercent = 100, horizontalStep = 4, verticalStep = -3).safeArea
assertEquals(limit, beyond)
}
@Test
fun nearAnEdgeTheSafeAreaShrinksAroundTheMarker() {
val top = CarPlayClusterDisplay.config(1000, 1000, scalePercent = 100, verticalStep = -3).safeArea!!
// Marker at 15.5 % from the top: the area reaches the top edge and stays centred on it.
assertEquals(0, top.top)
assertEquals(310, 1000 - top.top - top.bottom)
assertEquals(15.5, CarPlayClusterDisplay.markerPercent(0, -3).second, 0.001)
}
@Test
fun theCarSitsNearThePanelCentre() {
val safe = CarPlayClusterDisplay.SAFE_AREA_PERCENT
val centreX = (safe.left + 100 - safe.right) / 2.0
assertTrue("centre x $centreX", centreX in 48.0..52.0)
}
}