From c57abecc62e3949c0cd3f0a087ca16d21e43737e Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Tue, 6 Oct 2026 16:27:37 -0700 Subject: [PATCH] send song data to car screens --- .../seb3thehacker/gearslip/GearslipRunner.kt | 18 +++ .../app/seb3thehacker/gearslip/Protobuf.kt | 3 + .../gearslip/ServiceDiscovery.kt | 10 ++ .../seb3thehacker/gearslip/car/CarServices.kt | 3 + .../gearslip/media/CarMediaStatus.kt | 119 ++++++++++++++++++ 5 files changed, 153 insertions(+) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt index 9dbb6aa..720f112 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipRunner.kt @@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip import android.os.Build import app.seb3thehacker.gearslip.audio.AudioLink import app.seb3thehacker.gearslip.audio.CarMic +import app.seb3thehacker.gearslip.media.CarMediaStatus import app.seb3thehacker.gearslip.car.CarAssistant import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarKeys @@ -68,6 +69,7 @@ class GearslipRunner( @Volatile private var inputChannelId: Int = -1 @Volatile private var inputKeycodes: List = emptyList() @Volatile private var sensorChannelId: Int = -1 + @Volatile private var mediaStatus: CarMediaStatus? = null private var sensorTypes: List = emptyList() @Volatile private var audioLink: AudioLink? = null private var audioMessagesSeen = 0 @@ -141,6 +143,8 @@ class GearslipRunner( audioLink = null CarSensors.clear() CarMic.current = null + mediaStatus?.stop() + mediaStatus = null lastSensorNight = null CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise projection?.onProjectionStopped() @@ -176,6 +180,8 @@ class GearslipRunner( onInputMessage(messageId, body) } else if (frame.channel == sensorChannelId) { onSensorMessage(messageId, body) + } else if (frame.channel == mediaStatus?.channelId) { + mediaStatus?.onMessage(messageId, body) } else if (frame.channel == CarMic.current?.channelId) { CarMic.current?.onMessage(messageId, body) } else if (frame.channel == audioLink?.channelId) { @@ -417,6 +423,7 @@ class GearslipRunner( startAudioChannel(serviceDiscoveryResponse) startSensorChannel(serviceDiscoveryResponse) startMicChannel(serviceDiscoveryResponse) + startMediaStatusChannel(serviceDiscoveryResponse) val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId) if (input == null) { @@ -478,6 +485,17 @@ class GearslipRunner( openChannel(mic.serviceId) } + /** What's playing, for the car's own screens: the cluster, the media source page. */ + private fun startMediaStatusChannel(serviceDiscoveryResponse: ByteArray) { + val channel = ServiceDiscovery.findMediaStatusChannel(serviceDiscoveryResponse) ?: run { + log.i("no media status channel - the car's own screens won't show what's playing") + return + } + log.i("media status channel: $channel") + mediaStatus = CarMediaStatus(channel) { id, body -> send(id, body, encrypted = true, channel = channel) } + openChannel(channel) + } + /** * Every sensor type the head unit advertised, subscribed to at once - there is no screen * to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt index e253147..c651284 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/Protobuf.kt @@ -31,6 +31,9 @@ object Protobuf { return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes } + fun bytesField(field: Int, bytes: ByteArray): ByteArray = + varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes + fun varintField(field: Int, value: Long): ByteArray = varint((field shl 3).toLong()) + varint(value) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt index 3cf57e6..686956f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ServiceDiscovery.kt @@ -110,6 +110,16 @@ object ServiceDiscovery { return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull() } + /** + * The channel the car takes "now playing" on (MediaPlaybackStatusService, Service field 9), + * which feeds its cluster and media screens. It carries no settings. Null when the car has none. + */ + fun findMediaStatusChannel(response: ByteArray): Int? = + Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel -> + val service = Wire.fields(channel) + if (service.any { it.number == 9 }) Wire.varint(service, 1)?.toInt() else null + } + /** * The car's microphone, from MediaSourceService (Service field 5): * MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2; diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt index cb8e114..3deb6de 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarServices.kt @@ -33,6 +33,9 @@ object CarServices { val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) } val media: CarMedia by lazy { CarMedia(requireNotNull(context)) } + /** [media], or null before the car screen has set things up: for code outside the car UI. */ + fun mediaOrNull(): CarMedia? = if (context != null) media else null + /** * A second, independent connection for templated apps that aren't navigation apps - Spotify's * own Car App Library service is the first of these. Kept apart from [nav] so opening one diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt new file mode 100644 index 0000000..0c1088a --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMediaStatus.kt @@ -0,0 +1,119 @@ +package app.seb3thehacker.gearslip.media + +import android.graphics.Bitmap +import android.media.session.PlaybackState +import app.seb3thehacker.gearslip.GearslipLog +import app.seb3thehacker.gearslip.Protobuf +import app.seb3thehacker.gearslip.car.CarServices +import java.io.ByteArrayOutputStream + +/** + * Tells the car what's playing, so its own screens show it: the instrument cluster, the media + * source screen, the steering wheel display. Android Auto does the same over the car's + * MediaPlaybackStatusService (Service field 9): + * + * -> MediaPlaybackMetadata { song = 1; artist = 2; album = 3; album_art = 4; duration_seconds = 6 } + * -> MediaPlaybackStatus { state = 1 (1 stopped, 2 playing, 3 paused); media_source = 2; + * playback_seconds = 3; shuffle = 4; repeat = 5; repeat_one = 6 } + * + * Read once a second from the media app Gearslip is driving. The track goes only when it changes; + * the status goes every second while playing, so the car's position counts along. + */ +class CarMediaStatus( + val channelId: Int, + private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit, +) { + @Volatile private var running = false + private var sentTrack: String? = null + private var sentState = -1 + private var inputsLogged = 0 + + fun onMessage(messageId: Int, body: ByteArray) { + when (messageId) { + MSG_CHANNEL_OPEN_RESPONSE -> { + val status = Protobuf.readInt32Field(body, 1) ?: 0 + GearslipLog.i("media status: <- ChannelOpenResponse: status=$status") + if (status == 0) start() + } + // The car's own buttons for the media source (play, next...). Logged to learn what + // this car sends; the steering wheel keys already arrive on the input channel. + MSG_INPUT -> if (inputsLogged++ < 10) GearslipLog.i("media status: <- input from the car:\n" + Protobuf.describe(body)) + else -> GearslipLog.i("media status: <- unhandled message id=$messageId (${body.size} bytes)") + } + } + + private fun start() { + if (running) return + running = true + Thread({ + while (running) { + runCatching { tick() }.onFailure { GearslipLog.w("media status: ${it.message}") } + Thread.sleep(TICK_MS) + } + }, "gearslip-media-status").start() + } + + fun stop() { + running = false + } + + private fun tick() { + val now = CarServices.mediaOrNull()?.now?.value + val active = now != null && now.isActive + val state = when { + !active -> STATE_STOPPED + now!!.state == PlaybackState.STATE_PAUSED -> STATE_PAUSED + else -> STATE_PLAYING + } + if (active) sendTrack(now!!) + // Nothing to say while stopped, beyond saying so once. + if (state == STATE_STOPPED && sentState == STATE_STOPPED) return + if (state == STATE_PAUSED && sentState == STATE_PAUSED) return + val source = CarServices.mediaApp.value?.label ?: "Gearslip" + val seconds = if (active) (now!!.currentPosition() / 1000).coerceAtLeast(0) else 0 + sendOnChannel( + MSG_STATUS, + Protobuf.varintField(1, state.toLong()) + + Protobuf.stringField(2, source) + + Protobuf.varintField(3, seconds) + + Protobuf.varintField(4, 0L) + + Protobuf.varintField(5, 0L) + + Protobuf.varintField(6, 0L), + ) + if (state != sentState) GearslipLog.i("media status: -> ${STATE_NAMES[state]} ($source)") + sentState = state + } + + private fun sendTrack(now: NowPlaying) { + val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art?.generationId}" + if (key == sentTrack) return + sentTrack = key + var body = Protobuf.stringField(1, now.title) + + Protobuf.stringField(2, now.artist) + + Protobuf.stringField(3, now.album) + now.art?.let { art -> artBytes(art)?.let { body += Protobuf.bytesField(4, it) } } + if (now.durationMs > 0) body += Protobuf.varintField(6, now.durationMs / 1000) + sendOnChannel(MSG_METADATA, body) + GearslipLog.i("media status: -> track \"${now.title}\" by ${now.artist.ifBlank { "?" }}") + } + + /** Album art as a small PNG: car screens show it at thumbnail size, and the link is shared with video. */ + private fun artBytes(art: Bitmap): ByteArray? = runCatching { + val scale = minOf(1f, ART_PX.toFloat() / maxOf(art.width, art.height)) + val small = if (scale < 1f) Bitmap.createScaledBitmap(art, (art.width * scale).toInt(), (art.height * scale).toInt(), true) else art + ByteArrayOutputStream().also { small.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray() + }.getOrNull() + + companion object { + private const val TICK_MS = 1_000L + private const val ART_PX = 256 + private const val MSG_CHANNEL_OPEN_RESPONSE = 8 + private const val MSG_STATUS = 32769 + private const val MSG_INPUT = 32770 + private const val MSG_METADATA = 32771 + private const val STATE_STOPPED = 1 + private const val STATE_PLAYING = 2 + private const val STATE_PAUSED = 3 + private val STATE_NAMES = mapOf(1 to "stopped", 2 to "playing", 3 to "paused") + } +}