mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +01:00
send song data to car screens
This commit is contained in:
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user