send song data to car screens

This commit is contained in:
Seb3thehacker committed 2026-10-06 23:33:18 -07:00
1 parent dfebae93b3
commit c57abecc62
5 files changed
+153

No files matched your search

@@ -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<Int> = emptyList()
@Volatile private var sensorChannelId: Int = -1
@Volatile private var mediaStatus: CarMediaStatus? = null
private var sensorTypes: List<Int> = 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
@@ -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)
@@ -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;
@@ -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
@@ -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")
}
}