mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac389d2fd | ||
|
|
95fddf5a55 | ||
|
|
206dd67d98 | ||
|
|
817b413eb1 | ||
|
|
03f27c4dda | ||
|
|
42b151034a | ||
|
|
7a774b4091 | ||
|
|
2b8b221d1b |
No files matched your search
@@ -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.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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 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.
|
||||
- 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.
|
||||
- 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).
|
||||
- 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.
|
||||
|
||||
## For a few cars
|
||||
- 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.
|
||||
- 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.
|
||||
## Testing
|
||||
- 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.
|
||||
|
||||
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/).
|
||||
@@ -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
|
||||
@@ -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, 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.
|
||||
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
|
||||
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
|
||||
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
|
||||
play the best match.
|
||||
@@ -78,11 +79,15 @@ screen-sharing mode.
|
||||
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
|
||||
The rest wait on the dashboard.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
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
|
||||
screen inside the car answers anything setup didn't cover.
|
||||
- **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
|
||||
tested against several real navigation and media apps.
|
||||
screen, and it draws its own keyboard for search, sign-in, and text fields. Alerts, such
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -38,9 +38,9 @@ android {
|
||||
applicationId = "app.seb3thehacker.gearslip"
|
||||
minSdk = 31
|
||||
targetSdk = 37
|
||||
versionCode = 14
|
||||
versionCode = 15
|
||||
// -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"
|
||||
// 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.
|
||||
|
||||
@@ -10,6 +10,14 @@ object Changelog {
|
||||
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
||||
|
||||
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(
|
||||
14, "0.3.39-alpha",
|
||||
listOf(
|
||||
|
||||
@@ -107,6 +107,8 @@ class GearslipRunner(
|
||||
@Volatile private var audioLink: AudioLink? = null
|
||||
@Volatile private var guidanceLink: AudioLink? = null
|
||||
@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 touchWidth = 0
|
||||
private var touchHeight = 0
|
||||
@@ -260,6 +262,8 @@ class GearslipRunner(
|
||||
CarBluetooth.onMessage(messageId, body)
|
||||
} else if (frame.channel == vendorChannelId) {
|
||||
onVendorMessage(messageId, body)
|
||||
} else if (frame.channel == wifiChannelId) {
|
||||
onWifiProjectionMessage(messageId, body)
|
||||
} else if (frame.channel == CarMic.current?.channelId) {
|
||||
CarMic.current?.onMessage(messageId, body)
|
||||
} else if (frame.channel == audioLink?.channelId) {
|
||||
@@ -726,6 +730,23 @@ class GearslipRunner(
|
||||
vendorChannelId = gm.serviceId
|
||||
log.i("-> ChannelOpenRequest on GM's channel ${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) {
|
||||
@@ -1317,15 +1338,20 @@ class GearslipRunner(
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks the car for its screen; the answer comes to [onVideoFocus]. */
|
||||
private fun requestProjectedFocus() {
|
||||
// VideoFocusRequestNotification { 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 focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
|
||||
/**
|
||||
* Asks the car for its screen; the answer comes to [onVideoFocus]. [withDisplay] names the
|
||||
* display's channel too, as the request for the car's own screen does (see [requestNativeFocus]):
|
||||
* the field is deprecated, but a car older than that may ignore a request without it.
|
||||
*/
|
||||
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)
|
||||
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
|
||||
* 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.
|
||||
* 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() {
|
||||
Thread({
|
||||
Thread.sleep(WITHHELD_SCREEN_MS)
|
||||
if (!running || videoStarted) return@Thread
|
||||
val open = CarAudio.openSink ?: return@Thread
|
||||
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - opening the media channel and asking again")
|
||||
open()
|
||||
// Give the car the channel's open response before it hears the request again.
|
||||
Thread.sleep(1_000)
|
||||
if (running && !videoStarted) requestProjectedFocus()
|
||||
val open = CarAudio.openSink
|
||||
if (open != null) {
|
||||
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - opening the media channel and asking again")
|
||||
open()
|
||||
// Give the car the channel's open response before it hears the request again.
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -200,6 +200,13 @@ object ServiceDiscovery {
|
||||
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? {
|
||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||
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() {
|
||||
car = null
|
||||
pairing = false
|
||||
@@ -157,6 +171,10 @@ object CarBluetooth {
|
||||
else main.post { startBond("the car wouldn't open its Bluetooth channel") }
|
||||
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 }
|
||||
?: c.service.pairingMethods.firstOrNull() ?: METHOD_NUMERIC_COMPARISON
|
||||
c.send(
|
||||
|
||||
@@ -31,6 +31,11 @@ object CarServices {
|
||||
|
||||
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 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
|
||||
* 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.
|
||||
* 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") {
|
||||
pausedForNow = null
|
||||
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. */
|
||||
@@ -293,7 +309,7 @@ object CarServices {
|
||||
val manager = ctx.getSystemService(MediaSessionManager::class.java)
|
||||
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
|
||||
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) {
|
||||
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.
|
||||
val likeAction = now.likeAction
|
||||
if (likeAction != null) {
|
||||
CustomActionButton(likeAction, media, size, toggle = true)
|
||||
CustomActionButton(likeAction, media, size, on = liked == true)
|
||||
} else {
|
||||
GsIconButton(
|
||||
if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||
@@ -158,7 +158,7 @@ internal fun PlayModeRow(
|
||||
}
|
||||
val shuffleAction = now.shuffleAction
|
||||
if (shuffleAction != null) {
|
||||
CustomActionButton(shuffleAction, media, size, toggle = true)
|
||||
CustomActionButton(shuffleAction, media, size, on = shuffleAction.on)
|
||||
} else {
|
||||
GsIconButton(
|
||||
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
|
||||
if (repeatAction != null) {
|
||||
CustomActionButton(repeatAction, media, size, toggle = true)
|
||||
CustomActionButton(repeatAction, media, size, on = repeatAction.on)
|
||||
} else {
|
||||
GsIconButton(
|
||||
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.
|
||||
*/
|
||||
@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)
|
||||
GsIconBox(
|
||||
onClick = { media.custom(action) },
|
||||
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) {
|
||||
Icon(icon, action.name, Modifier.size(size * 0.52f))
|
||||
|
||||
@@ -44,15 +44,27 @@ class MediaEntry(
|
||||
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
|
||||
* 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".
|
||||
* 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
|
||||
get() = iconName.lowercase().split('_', '.', '-').any { it in ON_WORDS }
|
||||
get() = hasOutline || iconName.lowercase().split('_', '.', '-').any { it in ON_WORDS }
|
||||
|
||||
private companion object {
|
||||
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.
|
||||
*/
|
||||
val liked: Boolean?
|
||||
get() = heart ?: likeAction?.name?.let { name ->
|
||||
listOf("remove", "unlike", "unfav", "unheart").any { name.contains(it, ignoreCase = true) }
|
||||
get() = heart ?: likeAction?.let { action ->
|
||||
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
|
||||
@@ -392,7 +404,10 @@ class CarMedia(private val context: Context) {
|
||||
speed = state?.playbackSpeed ?: 1f,
|
||||
updatedAt = state?.lastPositionUpdateTime ?: 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(),
|
||||
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
|
||||
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
|
||||
|
||||
/** 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
|
||||
* 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? {
|
||||
if (appResources?.first != pkg) {
|
||||
appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull()
|
||||
outlines.clear()
|
||||
}
|
||||
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 {
|
||||
val pkg = controller?.packageName ?: return ""
|
||||
val res = resourcesOf(pkg) ?: return ""
|
||||
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
|
||||
|
||||
/** 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. */
|
||||
const val SEARCH_WAIT_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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user