song search

This commit is contained in:
Seb3thehacker committed 2026-10-02 11:43:28 -07:00
1 parent 07d164ac74
commit 913e4af133
4 files changed
+143 -2

No files matched your search

+1 -1
View File
@@ -21,7 +21,7 @@ extraction/
# Internal
*.pdf
todo
todo.md
logs/
# For AI code
@@ -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) {
@@ -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,
@@ -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<List<QueueTrack>>(emptyList())
val queue: StateFlow<List<QueueTrack>> = _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<Search?>(null)
val search: StateFlow<Search?> = _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
}
}