Author SHA1 Message Date
Seb3thehacker 4ac389d2fd 0.3.40-alpha: version and release notes 2026-11-04 22:32:56 -08:00
Seb3thehacker 95fddf5a55 GM cars also get their Bluetooth and Wi-Fi projection channels opened
The 2021 Bolt still hangs up about 20 seconds in with pings answered and GM's channel open.
These are the two channels it offers that Gearslip had never opened. What arrives on them is
logged. Without an address typed in Settings, no PairingRequest is sent.
2026-11-04 22:27:21 -08:00
Seb3thehacker 206dd67d98 Music started again after the car left is paused again
Deezer sometimes carried on out of the phone's speaker after the car was turned off. Apps that
are buffering are paused too, and two seconds later any that started by themselves are paused
again.
2026-11-04 22:15:39 -08:00
Seb3thehacker 817b413eb1 A car that keeps its screen is asked again with the display's channel named
The 2017 Pilot (protocol 1.0) never answered the request for its screen, even once the media
channel was open. The deprecated display field is what the 2018 Uconnect needed to give its
screen back, so the retry sends it too. The retry now also runs on cars with no media channel.
2026-11-04 22:15:39 -08:00
Seb3thehacker 03f27c4dda An app's own like button lights the whole button when liked, like shuffle and repeat
Metrolist swaps ic_heart_outline for ic_heart and keeps the name, so only the heart filled
in. A plain icon that has an outline twin in the app now counts as on.
2026-11-04 22:02:38 -08:00
Seb3thehacker 42b151034a README: Waze works on the car screen 2026-11-04 20:39:38 -08:00
Seb3thehacker 7a774b4091 README covers launcher mode, phone map turns, hidden apps, density and languages 2026-11-04 20:36:24 -08:00
Seb3thehacker 2b8b221d1b A pull request that edits a translation fails, pointing to Weblate
Translated strings are Weblate's alone. A PR that changed them collided
with Weblate's copy, and Weblate locked itself until it was reset.
2026-11-04 20:28:31 -08:00
11 changed files with 181 additions and 61 deletions

No files matched your search

+8 -26
View File
@@ -1,31 +1,13 @@
Gearslip 0.3.39 runs as a head unit's home screen, connects to more cars, and brings Google Maps and Waze turns to the car. Gearslip 0.3.40 is a test build for a few cars that still don't connect.
This is a pre-release. Many of these fixes target cars we don't own, so please try it and tell us how your car does. Attach a log to your issue: open Gearslip, tap Logs, then Share log. If your car works on 0.3.39, you don't need this one. If you're following #18, #39 or #57, please try it and attach a log to your issue: open Gearslip, tap Logs, then Share log.
## New
- Launcher mode: on a head unit that runs Android, or a spare phone or tablet on the dash, Gearslip can be the home screen. Turn it on under Settings > Connection. An All apps page opens everything else.
- Turns from Google Maps or Waze on the phone show in the car's nav bar and on its instrument cluster.
- An Apps card on the phone's home screen lets you hide apps from the car's drawer. Thanks to @johnneerdael.
- A display density setting makes text and buttons bigger in car apps on small screens. Thanks to @johnneerdael.
- A Language setting picks Gearslip's language, and spoken directions use a voice in that language.
- Alerts from car apps show on the car screen, such as Waze asking whether a hazard is still there.
- AntennaPod gets a Next chapter button.
- The car list shows the names you gave your Bluetooth devices. Thanks to @theo330.
- A car you once turned away can connect on its own again, from the car list.
## Fixed ## Fixed
- Cars that waited for the music channel before showing a picture now show Gearslip's screen. This should fix the black screen or endless "Starting video" on the 2017 Pilot, 2020 Civic, 2021 CX-5 and 2024 Mirage. - A car that holds back its screen is asked for it again with the detail older cars expect. This is for the 2017 Pilot (#57) and other cars on the oldest Android Auto version.
- Gearslip sends the car its icon and a session ID, as a real phone does. Older Hyundais that stopped after the first request should now connect. - Music no longer carries on from the phone's speaker after you turn the car off. An app that started playing again by itself is paused a second time (#39).
- Gearslip pings the car once a second, as a real phone does. This may stop the 2021 Bolt from hanging up after 25 seconds. - A liked song lights up the whole like button, as shuffle and repeat do. Before, only the heart filled in for apps such as Metrolist.
- A still car screen gets a fresh frame every second, which should fix the black screen on some aftermarket boxes.
- Wireless keeps calling the car until it answers, starts over after a failed step, and stays connected with the phone locked.
- Music playing through Gearslip comes back after a call.
- Car maps keep the phone's position while the phone is locked.
- Spoken directions start the speech engine again when it fails to start.
- Error messages say when the car's clock is wrong, and what to try when a connection gets stuck.
## For a few cars ## Testing
- Directions over the music channel, under Spoken directions in the car's settings: for a car that drops the connection or restarts after a spoken turn. - GM cars open two more channels, Bluetooth and Wi-Fi projection, and log what the car sends on them. This is to find out why the 2021 Bolt hangs up about 20 seconds in (#18). If you drive a GM car, type your phone's Bluetooth address under Settings > Connection > Phone's Bluetooth address. You'll find it in Android's Settings under About phone.
- Phone's Bluetooth address, under Settings > Connection: for a car that drops the phone's Bluetooth when Gearslip connects. Copy the address from Android's Settings > About phone.
New text is in English until translators catch up. Help with your language on [Weblate](https://hosted.weblate.org/engage/gearslip/). Help translate Gearslip on [Weblate](https://hosted.weblate.org/engage/gearslip/).
+18
View File
@@ -0,0 +1,18 @@
name: Translations
# Weblate is the only thing that writes translated strings: it pushes straight to experimental,
# never through a pull request. A PR that edits them collides with Weblate's own copy, and
# Weblate locks itself until someone resets it. Edit the English strings.xml only.
on:
pull_request:
paths:
- "gearslip/src/main/res/values-*/strings.xml"
jobs:
weblate-only:
runs-on: ubuntu-latest
steps:
- name: Translations go through Weblate
run: |
echo "::error::This pull request edits a translation. Please make translation changes on Weblate instead: https://hosted.weblate.org/engage/gearslip/ (it can upload a whole strings.xml under Files > Upload translation). See CONTRIBUTING.md."
exit 1
+15 -6
View File
@@ -59,9 +59,10 @@ screen-sharing mode.
call goes out. When a call comes in, answer or decline it on the car screen. During a call goes out. When a call comes in, answer or decline it on the car screen. During a
call, the nav bar shows who you're talking to, and you mute or hang up from there. call, the nav bar shows who you're talking to, and you mute or hang up from there.
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen. - **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
Organic Maps, Vela, OsmAnd, Flitsmeister, MAPS.ME, and MapQuest work today. While you Organic Maps, Vela, OsmAnd, Waze, Flitsmeister, MAPS.ME, and MapQuest work today. While you
navigate, a big turn card on the left shows the arrow, the distance, and your lane, and navigate, a big turn card on the left shows the arrow, the distance, and your lane, and
the map keeps the right side. the map keeps the right side. Turns from Google Maps or Waze running on the phone show in
the nav bar, and on the instrument cluster in cars with a screen there.
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now - **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
play the best match. play the best match.
@@ -78,11 +79,15 @@ screen-sharing mode.
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you. - **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
The rest wait on the dashboard. The rest wait on the dashboard.
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music, - **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
messaging, and the rest. Press and hold an app to move it. messaging, and the rest. Press and hold an app to move it, and hide the ones you don't
use from the Apps card on the phone.
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work. - **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
- **Your phone's colors.** The car screen and the phone app take their colors from your - **Your phone's colors.** The car screen and the phone app take their colors from your
phone's Material You palette, and every card and button matches. phone's Material You palette, and every card and button matches.
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys. - **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
- **Small screens.** Car display density makes text and buttons bigger in car apps.
- **Your language.** Gearslip comes in 19 languages besides English, some only partly
translated. Pick one in Settings. Voice commands understand English only for now.
- **Settings on the phone.** The car's settings sit on the phone's home screen too, so you - **Settings on the phone.** The car's settings sit on the phone's home screen too, so you
can set things up before you drive. A change on either screen shows on both. can set things up before you drive. A change on either screen shows on both.
@@ -96,11 +101,15 @@ screen-sharing mode.
then one permission at a time, each with a plain reason and an Allow or Skip. A help then one permission at a time, each with a plain reason and an Allow or Skip. A help
screen inside the car answers anything setup didn't cover. screen inside the car answers anything setup didn't cover.
- **Templated apps work.** Gearslip renders the Car App Library's templates on the car - **Templated apps work.** Gearslip renders the Car App Library's templates on the car
screen, and it draws its own keyboard for search, sign-in, and text fields. It has been screen, and it draws its own keyboard for search, sign-in, and text fields. Alerts, such
tested against several real navigation and media apps. as a map app asking whether a hazard is still there, show on the car screen too. It has
been tested against several real navigation and media apps.
- **Gearslip can be a head unit's home screen.** On a head unit that runs Android, or a
spare phone or tablet on the dash, turn on Use as launcher in Settings > Connection. The
car screen fills the display, and an All apps page opens everything else.
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit - **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
used in homemade car dashboards. used in homemade car dashboards.
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of - **Some apps refuse Gearslip.** Spotify and Google Messages use a version of
Google's car library that accepts only Android Auto itself. Spotify still plays through Google's car library that accepts only Android Auto itself. Spotify still plays through
Gearslip's own player. TIDAL lets only approved apps browse its library. Gearslip's own player. TIDAL lets only approved apps browse its library.
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the - **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
+2 -2
View File
@@ -38,9 +38,9 @@ android {
applicationId = "app.seb3thehacker.gearslip" applicationId = "app.seb3thehacker.gearslip"
minSdk = 31 minSdk = 31
targetSdk = 37 targetSdk = 37
versionCode = 14 versionCode = 15
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater. // -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.39-alpha" versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.40-alpha"
manifestPlaceholders["appLabel"] = "Gearslip" manifestPlaceholders["appLabel"] = "Gearslip"
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address, // Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent. // set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
@@ -10,6 +10,14 @@ object Changelog {
class Release(val versionCode: Int, val versionName: String, val changes: List<String>) class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
val releases = listOf( val releases = listOf(
Release(
15, "0.3.40-alpha",
listOf(
"A liked song lights up the whole like button, as shuffle and repeat do.",
"Music no longer carries on from the phone after you turn the car off.",
"Cars that hold back their screen are asked for it again in a way older cars understand.",
),
),
Release( Release(
14, "0.3.39-alpha", 14, "0.3.39-alpha",
listOf( listOf(
@@ -107,6 +107,8 @@ class GearslipRunner(
@Volatile private var audioLink: AudioLink? = null @Volatile private var audioLink: AudioLink? = null
@Volatile private var guidanceLink: AudioLink? = null @Volatile private var guidanceLink: AudioLink? = null
@Volatile private var vendorChannelId = -1 @Volatile private var vendorChannelId = -1
/** The car's Wi-Fi projection channel, opened in GM cars only (see [startVendorChannel]); -1 when closed. */
@Volatile private var wifiChannelId = -1
private var audioMessagesSeen = 0 private var audioMessagesSeen = 0
private var touchWidth = 0 private var touchWidth = 0
private var touchHeight = 0 private var touchHeight = 0
@@ -260,6 +262,8 @@ class GearslipRunner(
CarBluetooth.onMessage(messageId, body) CarBluetooth.onMessage(messageId, body)
} else if (frame.channel == vendorChannelId) { } else if (frame.channel == vendorChannelId) {
onVendorMessage(messageId, body) onVendorMessage(messageId, body)
} else if (frame.channel == wifiChannelId) {
onWifiProjectionMessage(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) {
@@ -726,6 +730,23 @@ class GearslipRunner(
vendorChannelId = gm.serviceId vendorChannelId = gm.serviceId
log.i("-> ChannelOpenRequest on GM's channel ${gm.serviceId}") log.i("-> ChannelOpenRequest on GM's channel ${gm.serviceId}")
openChannel(gm.serviceId) openChannel(gm.serviceId)
// Opening GM's channel and pinging the car didn't stop the Bolt hanging up, so a GM car
// also gets the two channels Gearslip had never opened: Bluetooth and Wi-Fi projection.
// Only what arrives on them is logged, by message id and size, until one turns out to matter.
CarBluetooth.announce("a GM car, issue #18")
ServiceDiscovery.findWifiProjectionService(serviceDiscoveryResponse)?.let { channel ->
wifiChannelId = channel
log.i("-> ChannelOpenRequest on the Wi-Fi projection channel $channel (a GM car, issue #18)")
openChannel(channel)
}
}
private fun onWifiProjectionMessage(messageId: Int, body: ByteArray) {
if (messageId == MSG_CHANNEL_OPEN_RESPONSE) {
log.i("<- Wi-Fi projection channel open: status=${Protobuf.readInt32Field(body, 1) ?: 0}")
} else {
log.i("<- Wi-Fi projection message id=$messageId (${body.size} bytes)")
}
} }
private fun onVendorMessage(messageId: Int, body: ByteArray) { private fun onVendorMessage(messageId: Int, body: ByteArray) {
@@ -1317,15 +1338,20 @@ class GearslipRunner(
} }
} }
/** Asks the car for its screen; the answer comes to [onVideoFocus]. */ /**
private fun requestProjectedFocus() { * Asks the car for its screen; the answer comes to [onVideoFocus]. [withDisplay] names the
// VideoFocusRequestNotification { mode = 2; reason = 3 }. Reason 1 means "the phone's * display's channel too, as the request for the car's own screen does (see [requestNativeFocus]):
// screen is off", which a 2020 Civic may have taken as a reason to keep its own * the field is deprecated, but a car older than that may ignore a request without it.
// screen (issue #43); 0 is UNKNOWN. */
val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) + private fun requestProjectedFocus(withDisplay: Boolean = false) {
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }. Reason 1
// means "the phone's screen is off", which a 2020 Civic may have taken as a reason to
// keep its own screen (issue #43); 0 is UNKNOWN.
val display = if (withDisplay) Protobuf.varintField(1, videoChannelId.toLong()) else ByteArray(0)
val focus = display + Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
Protobuf.varintField(3, 0L) Protobuf.varintField(3, 0L)
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId) send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
log.i("-> VideoFocusRequest(mode=PROJECTED)") log.i(if (withDisplay) "-> VideoFocusRequest(display=$videoChannelId, mode=PROJECTED)" else "-> VideoFocusRequest(mode=PROJECTED)")
} }
/** /**
@@ -1335,17 +1361,23 @@ class GearslipRunner(
* music over Bluetooth that channel waits for the car to switch source, so if the screen * music over Bluetooth that channel waits for the car to switch source, so if the screen
* hasn't come a few seconds after asking, open it and ask again. Cars that hand the screen * hasn't come a few seconds after asking, open it and ask again. Cars that hand the screen
* straight over keep their own source as before. * straight over keep their own source as before.
* The 2017 Pilot (protocol 1.0) still kept its screen with the channel open, so the second
* request also names the display's channel, as cars of the Pilot's age may need.
*/ */
private fun watchForWithheldScreen() { private fun watchForWithheldScreen() {
Thread({ Thread({
Thread.sleep(WITHHELD_SCREEN_MS) Thread.sleep(WITHHELD_SCREEN_MS)
if (!running || videoStarted) return@Thread if (!running || videoStarted) return@Thread
val open = CarAudio.openSink ?: return@Thread val open = CarAudio.openSink
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - opening the media channel and asking again") if (open != null) {
open() log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - opening the media channel and asking again")
// Give the car the channel's open response before it hears the request again. open()
Thread.sleep(1_000) // Give the car the channel's open response before it hears the request again.
if (running && !videoStarted) requestProjectedFocus() Thread.sleep(1_000)
} else {
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - asking again")
}
if (running && !videoStarted) requestProjectedFocus(withDisplay = true)
}, "gearslip-focus-fallback").apply { isDaemon = true }.start() }, "gearslip-focus-fallback").apply { isDaemon = true }.start()
} }
@@ -200,6 +200,13 @@ object ServiceDiscovery {
VendorService(id, name) VendorService(id, name)
} }
/** The channel of the car's Wi-Fi projection service (Service field 14), or null. */
fun findWifiProjectionService(response: ByteArray): Int? =
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
val service = Wire.fields(channel)
if (Wire.bytes(service, 14) == null) null else Wire.varint(service, 1)?.toInt()
}
fun findBluetoothService(response: ByteArray): BluetoothService? { fun findBluetoothService(response: ByteArray): BluetoothService? {
for (channel in Wire.allBytes(Wire.fields(response), 1)) { for (channel in Wire.allBytes(Wire.fields(response), 1)) {
val service = Wire.fields(channel) val service = Wire.fields(channel)
@@ -105,6 +105,20 @@ object CarBluetooth {
} }
} }
/**
* Opens the car's Bluetooth channel though there's no pairing to do, as a real phone does on
* every connection. Issue #18: a 2021 Bolt hangs up about 20 seconds in, and this is one of
* the two channels it offers that Gearslip never opened. Without an address the driver typed,
* it only opens the channel: a made-up address could set the car pairing with the wrong phone.
*/
fun announce(why: String) {
val c = car ?: return
if (pairing || announcing) return
announcing = true
log.i("-> ChannelOpenRequest on channel ${c.service.serviceId} ($why)")
c.open()
}
fun onCarDisconnected() { fun onCarDisconnected() {
car = null car = null
pairing = false pairing = false
@@ -157,6 +171,10 @@ object CarBluetooth {
else main.post { startBond("the car wouldn't open its Bluetooth channel") } else main.post { startBond("the car wouldn't open its Bluetooth channel") }
return return
} }
if (announcing && typedAddress() == null) {
log.i("Bluetooth channel open; no address typed in Settings, so no PairingRequest")
return
}
val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON } val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON }
?: c.service.pairingMethods.firstOrNull() ?: METHOD_NUMERIC_COMPARISON ?: c.service.pairingMethods.firstOrNull() ?: METHOD_NUMERIC_COMPARISON
c.send( c.send(
@@ -31,6 +31,11 @@ object CarServices {
private var context: Context? = null private var context: Context? = null
/** Buffering counts as playing: an app between songs is about to make sound again. */
private val PLAYING_STATES = setOf(PlaybackState.STATE_PLAYING, PlaybackState.STATE_BUFFERING)
/** How long after pausing for the car leaving to look again for an app that started by itself. */
private const val REPAUSE_MS = 2_000L
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)) }
@@ -253,11 +258,22 @@ object CarServices {
* the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way * the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way
* headphones are, so the apps won't pause themselves. Every playing session is paused, not * headphones are, so the apps won't pause themselves. Every playing session is paused, not
* just the one Gearslip opened: anything the car was playing was going through the car. * just the one Gearslip opened: anything the car was playing was going through the car.
* An app can start again on its own once the speaker is given back a moment later, which a
* Deezer driver heard after turning the car off (issue #39), so the ones it paused are
* looked at once more and paused again if they're playing.
*/ */
fun pausePhoneMedia(why: String = "the car left") { fun pausePhoneMedia(why: String = "the car left") {
pausedForNow = null pausedForNow = null
pausedOwnForNow = false pausedOwnForNow = false
pause(why) val paused = pause(why)
if (paused.isEmpty()) return
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
val again = paused.filter { it.playbackState?.state in PLAYING_STATES }
again.forEach { runCatching { it.transportControls.pause() } }
if (again.isNotEmpty()) {
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${again.joinToString { it.packageName }} again: it started by itself after $why")
}
}, REPAUSE_MS)
} }
/** What [pausePhoneMediaForNow] paused, for [resumePhoneMedia] to play again. */ /** What [pausePhoneMediaForNow] paused, for [resumePhoneMedia] to play again. */
@@ -293,7 +309,7 @@ object CarServices {
val manager = ctx.getSystemService(MediaSessionManager::class.java) val manager = ctx.getSystemService(MediaSessionManager::class.java)
val listener = ComponentName(ctx, GearslipNotificationListener::class.java) val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
val playing = try { val playing = try {
manager.getActiveSessions(listener).filter { it.playbackState?.state == PlaybackState.STATE_PLAYING } manager.getActiveSessions(listener).filter { it.playbackState?.state in PLAYING_STATES }
} catch (e: SecurityException) { } catch (e: SecurityException) {
null null
} }
@@ -145,7 +145,7 @@ internal fun PlayModeRow(
// Gearslip's own stand in for apps that report a heart rating or shuffle and repeat modes. // Gearslip's own stand in for apps that report a heart rating or shuffle and repeat modes.
val likeAction = now.likeAction val likeAction = now.likeAction
if (likeAction != null) { if (likeAction != null) {
CustomActionButton(likeAction, media, size, toggle = true) CustomActionButton(likeAction, media, size, on = liked == true)
} else { } else {
GsIconButton( GsIconButton(
if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
@@ -158,7 +158,7 @@ internal fun PlayModeRow(
} }
val shuffleAction = now.shuffleAction val shuffleAction = now.shuffleAction
if (shuffleAction != null) { if (shuffleAction != null) {
CustomActionButton(shuffleAction, media, size, toggle = true) CustomActionButton(shuffleAction, media, size, on = shuffleAction.on)
} else { } else {
GsIconButton( GsIconButton(
MediaIcons.Shuffle, stringResource(if (shuffleOn) R.string.player_shuffle_off else R.string.player_shuffle_on), MediaIcons.Shuffle, stringResource(if (shuffleOn) R.string.player_shuffle_off else R.string.player_shuffle_on),
@@ -170,7 +170,7 @@ internal fun PlayModeRow(
} }
val repeatAction = now.repeatAction val repeatAction = now.repeatAction
if (repeatAction != null) { if (repeatAction != null) {
CustomActionButton(repeatAction, media, size, toggle = true) CustomActionButton(repeatAction, media, size, on = repeatAction.on)
} else { } else {
GsIconButton( GsIconButton(
if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat, if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat,
@@ -191,12 +191,12 @@ internal fun PlayModeRow(
* not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load. * not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load.
*/ */
@Composable @Composable
private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp, toggle: Boolean = false) { private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp, on: Boolean = false) {
val icon = rememberActionIcon(action) val icon = rememberActionIcon(action)
GsIconBox( GsIconBox(
onClick = { media.custom(action) }, onClick = { media.custom(action) },
modifier = Modifier.size(size), modifier = Modifier.size(size),
colors = gsColors(if (toggle && action.on) GsTone.Primary else GsTone.Neutral), colors = gsColors(if (on) GsTone.Primary else GsTone.Neutral),
) { ) {
if (icon != null) { if (icon != null) {
Icon(icon, action.name, Modifier.size(size * 0.52f)) Icon(icon, action.name, Modifier.size(size * 0.52f))
@@ -44,15 +44,27 @@ class MediaEntry(
val group: String? = null, val group: String? = null,
) )
/** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */ /**
class CustomAction(val id: String, val name: String, val extras: Bundle?, val icon: Int = 0, val iconName: String = "") { * [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none.
* [hasOutline] says the app also has an outline of the same icon (ic_heart beside ic_heart_outline).
*/
class CustomAction(
val id: String,
val name: String,
val extras: Bundle?,
val icon: Int = 0,
val iconName: String = "",
val hasOutline: Boolean = false,
) {
/** /**
* Whether a toggle like heart or shuffle is on, read from the icon's name - Tidal swaps * Whether a toggle like heart or shuffle is on, read from the icon's name - Tidal swaps
* ic_favorite for ic_favorite_filled and ic_shuffle for ic_shuffle_active, and keeps the * ic_favorite for ic_favorite_filled and ic_shuffle for ic_shuffle_active, and keeps the
* button's name the same. Whole words only, so "inactive" doesn't count as "active". * button's name the same. Whole words only, so "inactive" doesn't count as "active".
* Metrolist swaps ic_heart_outline for a plain ic_heart, so a plain icon that has an outline
* twin counts as on too.
*/ */
val on: Boolean val on: Boolean
get() = iconName.lowercase().split('_', '.', '-').any { it in ON_WORDS } get() = hasOutline || iconName.lowercase().split('_', '.', '-').any { it in ON_WORDS }
private companion object { private companion object {
val ON_WORDS = setOf("filled", "fill", "active", "on", "selected", "checked", "enabled", "solid", "one", "all") val ON_WORDS = setOf("filled", "fill", "active", "on", "selected", "checked", "enabled", "solid", "one", "all")
@@ -118,8 +130,8 @@ class NowPlaying(
* only says which way it goes next by its name ("Remove from Liked Songs"), so that's read. * only says which way it goes next by its name ("Remove from Liked Songs"), so that's read.
*/ */
val liked: Boolean? val liked: Boolean?
get() = heart ?: likeAction?.name?.let { name -> get() = heart ?: likeAction?.let { action ->
listOf("remove", "unlike", "unfav", "unheart").any { name.contains(it, ignoreCase = true) } action.on || listOf("remove", "unlike", "unfav", "unheart").any { action.name.contains(it, ignoreCase = true) }
} }
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
@@ -392,7 +404,10 @@ class CarMedia(private val context: Context) {
speed = state?.playbackSpeed ?: 1f, speed = state?.playbackSpeed ?: 1f,
updatedAt = state?.lastPositionUpdateTime ?: 0, updatedAt = state?.lastPositionUpdateTime ?: 0,
actions = state?.actions ?: 0, actions = state?.actions ?: 0,
custom = withNextChapter(state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon, iconName(it.icon)) }), custom = withNextChapter(state?.customActions.orEmpty().map {
val iconName = iconName(it.icon)
CustomAction(it.action, it.name.toString(), it.extras, it.icon, iconName, hasOutline(it.icon, iconName))
}),
error = state?.errorMessage?.toString(), error = state?.errorMessage?.toString(),
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(), activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
heart = if (controller?.ratingType == Rating.RATING_HEART) { heart = if (controller?.ratingType == Rating.RATING_HEART) {
@@ -405,7 +420,6 @@ class CarMedia(private val context: Context) {
private var appResources: Pair<String, android.content.res.Resources?>? = null private var appResources: Pair<String, android.content.res.Resources?>? = null
/** The icon's resource name in the app's package, or "" when it can't be read. */
/** /**
* AntennaPod takes a Next chapter command whenever it's playing, but offers the button only * AntennaPod takes a Next chapter command whenever it's playing, but offers the button only
* when the driver turns it on in its notification settings. Gearslip offers it either way, * when the driver turns it on in its notification settings. Gearslip offers it either way,
@@ -424,16 +438,30 @@ class CarMedia(private val context: Context) {
private fun resourcesOf(pkg: String): android.content.res.Resources? { private fun resourcesOf(pkg: String): android.content.res.Resources? {
if (appResources?.first != pkg) { if (appResources?.first != pkg) {
appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull() appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull()
outlines.clear()
} }
return appResources?.second return appResources?.second
} }
/** The icon's resource name in the app's package, or "" when it can't be read. */
private fun iconName(icon: Int): String { private fun iconName(icon: Int): String {
val pkg = controller?.packageName ?: return "" val pkg = controller?.packageName ?: return ""
val res = resourcesOf(pkg) ?: return "" val res = resourcesOf(pkg) ?: return ""
return runCatching { res.getResourceEntryName(icon) }.getOrDefault("") return runCatching { res.getResourceEntryName(icon) }.getOrDefault("")
} }
/** Whether each icon has an outline twin, by icon id: looked up once, since state changes often. */
private val outlines = HashMap<Int, Boolean>()
private fun hasOutline(icon: Int, name: String): Boolean {
val pkg = controller?.packageName ?: return false
val res = resourcesOf(pkg) ?: return false
if (name.isEmpty() || OUTLINE_WORDS.any { name.contains(it, ignoreCase = true) }) return false
return outlines.getOrPut(icon) {
OUTLINE_WORDS.any { res.getIdentifier("${name}_$it", "drawable", pkg) != 0 }
}
}
private var loggedButtons: String? = null private var loggedButtons: String? = null
/** The app's own buttons, by id, name and icon, each time they change. */ /** The app's own buttons, by id, name and icon, each time they change. */
@@ -822,6 +850,8 @@ class CarMedia(private val context: Context) {
/** How long an app gets to start playing a search before Gearslip says it didn't. */ /** How long an app gets to start playing a search before Gearslip says it didn't. */
const val SEARCH_WAIT_MS = 6_000L const val SEARCH_WAIT_MS = 6_000L
const val SEARCH_NOTICE_MS = 6_000L const val SEARCH_NOTICE_MS = 6_000L
/** How apps name the empty version of an icon. */
val OUTLINE_WORDS = listOf("outline", "outlined", "border", "empty")
} }
} }