Let the dashboard show the turn card instead of the map

The iPhone lists three cluster contents in its /info altScreenURLs: the
map, an instruction (turn) card and "instrumentcluster", which draws the
map with the turn card on it. A new "Dashboard shows" choice picks the
one DiPlay asks for as the alt screen's initial URL; the map stays the
default. On the tested car the turn card fit the Small screen navi
window well and streamed 0–5 kbit/s against 0.3–4 Mbit/s for the map.

The iPhone lays out the map, the car marker and the card inside the
same safe area, so the existing position settings move whichever is
shown; they are now labelled "Car marker" or "Turn card" to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
georgiyrrandClaude Opus 5.5 committed 2026-09-29 16:55:38 +02:00
1 parent c3faa630a4
commit 11bc01c6e2
6 files changed
+54 -7

No files matched your search

@@ -54,6 +54,7 @@ object AirPlayPersistence {
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_SCALE = "cluster_map_scale_percent"
private const val KEY_CLUSTER_CONTENT = "cluster_content"
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"
@@ -410,6 +411,15 @@ object AirPlayPersistence {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putBoolean(KEY_CLUSTER_MAP, enabled).apply()
}
fun loadClusterContent(context: Context): CarPlayClusterDisplay.Content =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY_CLUSTER_CONTENT, null)
?.let { name -> CarPlayClusterDisplay.Content.entries.firstOrNull { it.name == name } }
?: CarPlayClusterDisplay.Content.MAP
fun saveClusterContent(context: Context, content: CarPlayClusterDisplay.Content) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY_CLUSTER_CONTENT, content.name).apply()
}
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
@@ -690,8 +690,9 @@ class CarPlayHostActivity : ComponentActivity() {
AirPlayPersistence.loadClusterMapScalePercent(this),
AirPlayPersistence.loadClusterMarkerHorizontalStep(this),
AirPlayPersistence.loadClusterMarkerVerticalStep(this),
AirPlayPersistence.loadClusterContent(this),
).also {
appendLog("Cluster map: requesting ${it.widthPixels}x${it.heightPixels} on ${size.x}x${size.y} safeArea=${it.safeArea}")
appendLog("Cluster map: requesting ${it.widthPixels}x${it.heightPixels} on ${size.x}x${size.y} safeArea=${it.safeArea} url=${it.initialUrl}")
}
}
@@ -281,21 +281,32 @@ class DiPlayActivity : ComponentActivity() {
}
} else {
val sizes = CarPlayClusterDisplay.scalePresets
choice(card, "Dashboard map size", listOf("Standard · sharpest", "Larger · default", "Largest"),
val contents = CarPlayClusterDisplay.Content.entries
val content = AirPlayPersistence.loadClusterContent(this)
choice(card, "Dashboard shows", listOf("Map · default", "Turn card", "Map with turn card"),
contents.indexOf(content)) {
AirPlayPersistence.saveClusterContent(this, contents[it])
render() // the size and position settings below are named after what is shown
}
// The iPhone lays out the map, the car marker and the turn card inside the same safe
// area, so the position settings move whichever the dashboard shows.
val shown = if (content == CarPlayClusterDisplay.Content.TURN_CARD) "Turn card" else "Car marker"
choice(card, if (content == CarPlayClusterDisplay.Content.TURN_CARD) "Turn card size" else "Dashboard map size",
listOf("Standard · sharpest", "Larger · default", "Largest"),
sizes.indexOf(AirPlayPersistence.loadClusterMapScalePercent(this)).coerceAtLeast(0)) {
AirPlayPersistence.saveClusterMapScalePercent(this, sizes[it])
}
val across = CarPlayClusterDisplay.horizontalSteps.toList()
choice(card, "Car marker · horizontal", across.map { markerStepLabel(it, "Left", "Right") },
choice(card, "$shown · 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") },
choice(card, "$shown · 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) {
card.addView(button("Reset ${shown.lowercase()} to centre", false) {
AirPlayPersistence.saveClusterMarkerHorizontalStep(this, 0)
AirPlayPersistence.saveClusterMarkerVerticalStep(this, 0)
render()
+2 -1
View File
@@ -22,7 +22,8 @@ Validated on DiLink5.0 / Android12, firmware `BYD-AUTO/DiLink5.0/DiLink5.0:12/SK
- 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.
- 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.
- "Dashboard 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.
- "Dashboard shows" picks which of the cluster contents the iPhone offers in `altScreenURLs` DiPlay asks for: Map (`maps:/car/instrumentcluster/map`, default), Turn card (`…/instructioncard`) or Map with turn card (`maps:/car/instrumentcluster`). On the tested car the turn card fit the Small screen navi window well and streamed only 0–5 kbit/s against 0.3–4 Mbit/s for the map. The iPhone lays out the map, the car marker and the turn card inside the same safe area, so the position settings below move whichever is shown; they are labelled "Car marker" or "Turn card" to match.
- "Dashboard map size" (or "Turn card 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.
@@ -11,6 +11,17 @@ package com.shilapi.xcertplay.airplay
object CarPlayClusterDisplay {
const val MAP_URL = "maps:/car/instrumentcluster/map"
/**
* What the dashboard shows: one of the cluster contents the iPhone lists in `altScreenURLs`.
* On the tested car the turn card needed 0–5 kbit/s against 0.3–4 Mbit/s for the map, and
* "instrumentcluster" drew the map with the turn card on it.
*/
enum class Content(val url: String) {
MAP(MAP_URL),
TURN_CARD("maps:/car/instrumentcluster/instructioncard"),
INSTRUMENTS("maps:/car/instrumentcluster"),
}
/**
* 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,
@@ -51,6 +62,7 @@ object CarPlayClusterDisplay {
scalePercent: Int = STREAM_SCALE_PERCENT,
horizontalStep: Int = 0,
verticalStep: Int = 0,
content: Content = Content.MAP,
): 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.
@@ -65,7 +77,7 @@ object CarPlayClusterDisplay {
fps = FPS,
primaryInputDevice = 0,
features = 0,
initialUrl = MAP_URL,
initialUrl = content.url,
safeArea = safeArea(width, height, horizontalStep, verticalStep),
safeAreaDrawOutside = true,
)
@@ -46,6 +46,18 @@ class CarPlayClusterDisplayTest {
assertEquals(0x0A, (displays[0] as Map<*, *>)["features"])
}
@Test
fun theDashboardCanAskForTheTurnCardInstead() {
val card = CarPlayClusterDisplay.config(1920, 720, content = CarPlayClusterDisplay.Content.TURN_CARD)
assertEquals("maps:/car/instrumentcluster/instructioncard", card.initialUrl)
// The three contents the iPhone lists in altScreenURLs.
assertEquals(
listOf("maps:/car/instrumentcluster/map", "maps:/car/instrumentcluster/instructioncard", "maps:/car/instrumentcluster"),
CarPlayClusterDisplay.Content.entries.map { it.url },
)
}
@Test
fun scaledStreamsKeepThePanelAspect() {
val sizes = (CarPlayClusterDisplay.scalePresets + 50).map {