diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt index 9a529ba..e0627d5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt @@ -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)) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt index 4941532..f96228c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt @@ -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? = 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() + + 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") } }