From 913e4af133906a6b2c938011361d6e0771725fa5 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Fri, 2 Oct 2026 11:43:28 -0700 Subject: [PATCH] song search --- .gitignore | 2 +- .../seb3thehacker/gearslip/car/MediaScreen.kt | 82 +++++++++++++++++++ .../gearslip/car/TemplateContent.kt | 2 +- .../seb3thehacker/gearslip/media/CarMedia.kt | 59 +++++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 0714ac2..1180a0b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,7 @@ extraction/ # Internal *.pdf -todo +todo.md logs/ # For AI code diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index fa8f6ee..dfcfe01 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -84,7 +85,9 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { val browse by media.browse.collectAsStateWithLifecycle() val rejection by media.rejection.collectAsStateWithLifecycle() val capture by CarAudio.capture.collectAsStateWithLifecycle() + val search by media.search.collectAsStateWithLifecycle() var tab by remember { mutableStateOf(Tab.NOW_PLAYING) } + var searching by remember { mutableStateOf(false) } // The connection outlives this screen: the home screen's player is the same one. LaunchedEffect(app) { CarServices.openMedia(app) } @@ -128,6 +131,19 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { return@Surface } + if (searching) { + SearchPanel( + appLabel = app.label, + onSearch = { query -> + media.search(query) + searching = false + tab = Tab.NOW_PLAYING + }, + onClose = { searching = false }, + ) + return@Surface + } + // Two columns, split the same way on every tab so nothing jumps when switching: the app // and the art (or, beside a list, the compact player) on the left; the tabs across the // top of the right column, over whatever the tab shows. @@ -151,7 +167,18 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } + if (now.canSearch) { + GsIconButton( + Icons.Filled.Search, + "Search ${app.label}", + { searching = true }, + colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface), + size = 44.dp, + shape = MaterialTheme.shapes.large, + ) + } } + search?.let { SearchStatus(it, app.label, media::dismissSearch) } Spacer(Modifier.height(12.dp)) Box(Modifier.weight(1f).fillMaxWidth()) { when (tab) { @@ -192,6 +219,61 @@ private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS } +// --- search ---------------------------------------------------------------------------------- + +/** + * Types a search for the app to play. The app picks the result and plays it, the way it would + * for "play X" from a voice assistant, so there's no list to choose from. + */ +@Composable +private fun SearchPanel(appLabel: String, onSearch: (String) -> Unit, onClose: () -> Unit) { + var query by remember { mutableStateOf("") } + Column(Modifier.fillMaxSize()) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + GsIconButton(Icons.Filled.ArrowBack, "Back", onClose, size = ICON_BUTTON_SIZE) + SearchField(query, "Search $appLabel", Modifier.weight(1f), onClear = { query = "" }) + } + Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 24.dp), contentAlignment = Alignment.Center) { + Text( + "Type a song, artist, album, or playlist. $appLabel picks what to play.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } + CarKeyboard( + text = query, + onTextChange = { query = it }, + onSubmit = { if (query.isNotBlank()) onSearch(query.trim()) }, + onDismiss = onClose, + ) + } +} + +/** A line under the tabs while the app works on a search, and if it plays nothing for it. */ +@Composable +private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: () -> Unit) { + Surface( + onClick = onDismiss, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { + Text( + if (search.ignored) "$appLabel didn't play anything for \"${search.query}\"." + else "Searching $appLabel for \"${search.query}\"…", + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) + } +} + /** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */ @Composable private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt index 1e10665..cd3c04e 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/TemplateContent.kt @@ -283,7 +283,7 @@ private fun SearchContent(template: SearchTemplate, modifier: Modifier) { * [onClear] adds a clear button once something is typed; [searchIcon] is off for sign-in fields. */ @Composable -private fun SearchField( +internal fun SearchField( query: String, hint: String, modifier: Modifier = Modifier, 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 a337ad2..9536d19 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt @@ -75,6 +75,9 @@ class NowPlaying( words.any { a.id.contains(it, ignoreCase = true) || a.name.contains(it, ignoreCase = true) } } + /** The app says it takes searches. Spotify says so and then ignores Gearslip's, so [CarMedia.search] checks. */ + val canSearch get() = (actions and PlaybackState.ACTION_PLAY_FROM_SEARCH) != 0L + /** Thumbs-down buttons say "like" too ("Dislike"), so those are skipped. */ val likeAction get() = custom.firstOrNull { a -> @@ -164,6 +167,13 @@ class CarMedia(private val context: Context) { private val _queue = MutableStateFlow>(emptyList()) val queue: StateFlow> = _queue.asStateFlow() + /** A typed search on its way to the app; [Search.ignored] once it has played nothing for it. */ + class Search(val query: String, val ignored: Boolean = false) + + private val _search = MutableStateFlow(null) + val search: StateFlow = _search.asStateFlow() + private var searchBefore: NowPlaying? = null + /** Shuffle and repeat, as [PlaybackStateCompat]'s modes; [PlaybackStateCompat.SHUFFLE_MODE_INVALID] when unknown. */ class Modes( val shuffle: Int = PlaybackStateCompat.SHUFFLE_MODE_INVALID, @@ -326,6 +336,7 @@ class CarMedia(private val context: Context) { metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false } else null, ) + checkSearch() } // --- browsing ------------------------------------------------------------------------ @@ -401,6 +412,50 @@ class CarMedia(private val context: Context) { fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras) fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId) + /** + * Asks the app to find and play [query]: a song, artist, album or playlist. This goes through + * the app's playback session, so it works for apps that keep their library from Gearslip, and + * the app stays in the background. Apps may still ignore it (Spotify only takes Google's), so + * if nothing new plays within [SEARCH_WAIT_MS] the search is marked ignored. + */ + fun search(query: String) { + val c = controller ?: return + main.removeCallbacks(searchTimeout) + searchBefore = _now.value + _search.value = Search(query) + GearslipLog.i("media: -> playFromSearch(\"$query\")") + c.transportControls.playFromSearch(query, Bundle()) + main.postDelayed(searchTimeout, SEARCH_WAIT_MS) + } + + fun dismissSearch() { + main.removeCallbacks(searchTimeout) + main.removeCallbacks(clearSearch) + _search.value = null + } + + private val searchTimeout = Runnable { + val pending = _search.value ?: return@Runnable + if (pending.ignored) return@Runnable + GearslipLog.w("media: nothing new played for \"${pending.query}\"") + _search.value = Search(pending.query, ignored = true) + main.postDelayed(clearSearch, SEARCH_NOTICE_MS) + } + + private val clearSearch = Runnable { _search.value = null } + + /** Something new is playing: another track, or the same one started from a pause. */ + private fun checkSearch() { + val pending = _search.value ?: return + val before = searchBefore ?: return + val now = _now.value + if (pending.ignored || !now.playing) return + if (now.title != before.title || !before.playing) { + GearslipLog.i("media: search played \"${now.title}\"") + dismissSearch() + } + } + /** The app's own like button if it has one, since that's the one its library listens to; else a heart rating. */ fun toggleLike() { val now = _now.value @@ -444,6 +499,7 @@ class CarMedia(private val context: Context) { _phase.value = Phase.IDLE _now.value = NowPlaying() _queue.value = emptyList() + dismissSearch() } /** @@ -479,5 +535,8 @@ class CarMedia(private val context: Context) { const val LAUNCH_WAIT_MS = 1_500L /** Retries once the app has been opened: a cold start can take a few seconds to publish. */ const val SESSION_RETRIES_LAUNCHED = 16 + /** 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 } }