mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +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 android.os.Build
|
||||||
import app.seb3thehacker.gearslip.audio.AudioLink
|
import app.seb3thehacker.gearslip.audio.AudioLink
|
||||||
import app.seb3thehacker.gearslip.audio.CarMic
|
import app.seb3thehacker.gearslip.audio.CarMic
|
||||||
|
import app.seb3thehacker.gearslip.media.CarMediaStatus
|
||||||
import app.seb3thehacker.gearslip.car.CarAssistant
|
import app.seb3thehacker.gearslip.car.CarAssistant
|
||||||
import app.seb3thehacker.gearslip.car.CarEnvironment
|
import app.seb3thehacker.gearslip.car.CarEnvironment
|
||||||
import app.seb3thehacker.gearslip.car.CarKeys
|
import app.seb3thehacker.gearslip.car.CarKeys
|
||||||
@@ -68,6 +69,7 @@ class GearslipRunner(
|
|||||||
@Volatile private var inputChannelId: Int = -1
|
@Volatile private var inputChannelId: Int = -1
|
||||||
@Volatile private var inputKeycodes: List<Int> = emptyList()
|
@Volatile private var inputKeycodes: List<Int> = emptyList()
|
||||||
@Volatile private var sensorChannelId: Int = -1
|
@Volatile private var sensorChannelId: Int = -1
|
||||||
|
@Volatile private var mediaStatus: CarMediaStatus? = null
|
||||||
private var sensorTypes: List<Int> = emptyList()
|
private var sensorTypes: List<Int> = emptyList()
|
||||||
@Volatile private var audioLink: AudioLink? = null
|
@Volatile private var audioLink: AudioLink? = null
|
||||||
private var audioMessagesSeen = 0
|
private var audioMessagesSeen = 0
|
||||||
@@ -141,6 +143,8 @@ class GearslipRunner(
|
|||||||
audioLink = null
|
audioLink = null
|
||||||
CarSensors.clear()
|
CarSensors.clear()
|
||||||
CarMic.current = null
|
CarMic.current = null
|
||||||
|
mediaStatus?.stop()
|
||||||
|
mediaStatus = null
|
||||||
lastSensorNight = null
|
lastSensorNight = null
|
||||||
CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise
|
CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise
|
||||||
projection?.onProjectionStopped()
|
projection?.onProjectionStopped()
|
||||||
@@ -176,6 +180,8 @@ class GearslipRunner(
|
|||||||
onInputMessage(messageId, body)
|
onInputMessage(messageId, body)
|
||||||
} else if (frame.channel == sensorChannelId) {
|
} else if (frame.channel == sensorChannelId) {
|
||||||
onSensorMessage(messageId, body)
|
onSensorMessage(messageId, body)
|
||||||
|
} else if (frame.channel == mediaStatus?.channelId) {
|
||||||
|
mediaStatus?.onMessage(messageId, body)
|
||||||
} else if (frame.channel == CarMic.current?.channelId) {
|
} else if (frame.channel == CarMic.current?.channelId) {
|
||||||
CarMic.current?.onMessage(messageId, body)
|
CarMic.current?.onMessage(messageId, body)
|
||||||
} else if (frame.channel == audioLink?.channelId) {
|
} else if (frame.channel == audioLink?.channelId) {
|
||||||
@@ -417,6 +423,7 @@ class GearslipRunner(
|
|||||||
startAudioChannel(serviceDiscoveryResponse)
|
startAudioChannel(serviceDiscoveryResponse)
|
||||||
startSensorChannel(serviceDiscoveryResponse)
|
startSensorChannel(serviceDiscoveryResponse)
|
||||||
startMicChannel(serviceDiscoveryResponse)
|
startMicChannel(serviceDiscoveryResponse)
|
||||||
|
startMediaStatusChannel(serviceDiscoveryResponse)
|
||||||
|
|
||||||
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
|
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
|
||||||
if (input == null) {
|
if (input == null) {
|
||||||
@@ -478,6 +485,17 @@ class GearslipRunner(
|
|||||||
openChannel(mic.serviceId)
|
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
|
* 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
|
* 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
|
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 =
|
fun varintField(field: Int, value: Long): ByteArray =
|
||||||
varint((field shl 3).toLong()) + varint(value)
|
varint((field shl 3).toLong()) + varint(value)
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ object ServiceDiscovery {
|
|||||||
return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull()
|
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):
|
* The car's microphone, from MediaSourceService (Service field 5):
|
||||||
* MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2;
|
* MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2;
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ object CarServices {
|
|||||||
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
|
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
|
||||||
val media: CarMedia by lazy { CarMedia(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
|
* 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
|
* 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