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 987b89c..36915b6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PlayerParts.kt @@ -138,29 +138,47 @@ internal fun PlayModeRow( val repeatOn = repeatKnown && modes.repeat != PlaybackStateCompat.REPEAT_MODE_NONE val icon = size * 0.52f Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) { - GsIconButton( - if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, - if (liked == true) "Unlike" else "Like", - media::toggleLike, - enabled = liked != null, - tone = if (liked == true) GsTone.Primary else GsTone.Neutral, - size = size, iconSize = icon, - ) - GsIconButton( - MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on", - media::toggleShuffle, - enabled = shuffleKnown || now.shuffleAction != null, - tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral, - size = size, iconSize = icon, - ) - GsIconButton( - if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat, - "Repeat", - media::cycleRepeat, - enabled = repeatKnown || now.repeatAction != null, - tone = if (repeatOn) GsTone.Primary else GsTone.Neutral, - size = size, iconSize = icon, - ) + // An app's own like, shuffle or repeat button shows whether it's on only through its icon, + // so that icon is drawn, as Android Auto does, and lit when its name says it's on. + // 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) + } else { + GsIconButton( + if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, + if (liked == true) "Unlike" else "Like", + media::toggleLike, + enabled = liked != null, + tone = if (liked == true) GsTone.Primary else GsTone.Neutral, + size = size, iconSize = icon, + ) + } + val shuffleAction = now.shuffleAction + if (shuffleAction != null) { + CustomActionButton(shuffleAction, media, size, toggle = true) + } else { + GsIconButton( + MediaIcons.Shuffle, if (shuffleOn) "Shuffle off" else "Shuffle on", + media::toggleShuffle, + enabled = shuffleKnown, + tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral, + size = size, iconSize = icon, + ) + } + val repeatAction = now.repeatAction + if (repeatAction != null) { + CustomActionButton(repeatAction, media, size, toggle = true) + } else { + GsIconButton( + if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat, + "Repeat", + media::cycleRepeat, + enabled = repeatKnown, + tone = if (repeatOn) GsTone.Primary else GsTone.Neutral, + size = size, iconSize = icon, + ) + } now.otherActions.take(maxOther).forEach { CustomActionButton(it, media, size) } trailing() } @@ -171,12 +189,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) { +private fun CustomActionButton(action: CustomAction, media: CarMedia, size: Dp, toggle: Boolean = false) { val icon = rememberActionIcon(action) GsIconBox( onClick = { media.custom(action) }, modifier = Modifier.size(size), - colors = gsColors(GsTone.Neutral), + colors = gsColors(if (toggle && action.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 a527974..dd0d9a2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt @@ -40,7 +40,19 @@ class MediaEntry( ) /** [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) +class CustomAction(val id: String, val name: String, val extras: Bundle?, val icon: Int = 0, val iconName: String = "") { + /** + * 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". + */ + val on: Boolean + get() = 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") + } +} /** One entry in the app's own queue - not its library, whatever it has lined up to play next. */ class QueueTrack( @@ -228,7 +240,11 @@ class CarMedia(private val context: Context) { private fun refreshModes() { val c = compat - _modes.value = if (c == null) Modes() else Modes(shuffle = c.shuffleMode, repeat = c.repeatMode) + val modes = if (c == null) Modes() else Modes(shuffle = c.shuffleMode, repeat = c.repeatMode) + if (modes.shuffle != _modes.value.shuffle || modes.repeat != _modes.value.repeat) { + GearslipLog.i("media: shuffle mode ${modes.shuffle}, repeat mode ${modes.repeat}, rating type ${controller?.ratingType}") + } + _modes.value = modes } private var subscribed: String? = null private var rootId: String? = null @@ -366,16 +382,39 @@ class CarMedia(private val context: Context) { speed = state?.playbackSpeed ?: 1f, updatedAt = state?.lastPositionUpdateTime ?: 0, actions = state?.actions ?: 0, - custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon) }, + custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon, iconName(it.icon)) }, error = state?.errorMessage?.toString(), activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(), heart = if (controller?.ratingType == Rating.RATING_HEART) { metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false } else null, ) + logButtons() checkSearch() } + private var appResources: Pair? = null + + /** 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 "" + if (appResources?.first != pkg) { + appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull() + } + val res = appResources?.second ?: return "" + return runCatching { res.getResourceEntryName(icon) }.getOrDefault("") + } + + private var loggedButtons: String? = null + + /** The app's own buttons, by id, name and icon, each time they change. */ + private fun logButtons() { + val text = _now.value.custom.joinToString { a -> "${a.id} \"${a.name}\" icon=${a.iconName.ifEmpty { a.icon.toString() }}" } + if (text == loggedButtons) return + loggedButtons = text + GearslipLog.i("media: app buttons: ${text.ifEmpty { "none" }}") + } + // --- browsing ------------------------------------------------------------------------ private fun open(parentId: String) {