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 # Internal
*.pdf *.pdf
todo todo.md
logs/ logs/
# For AI code # 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.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -84,7 +85,9 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
val browse by media.browse.collectAsStateWithLifecycle() val browse by media.browse.collectAsStateWithLifecycle()
val rejection by media.rejection.collectAsStateWithLifecycle() val rejection by media.rejection.collectAsStateWithLifecycle()
val capture by CarAudio.capture.collectAsStateWithLifecycle() val capture by CarAudio.capture.collectAsStateWithLifecycle()
val search by media.search.collectAsStateWithLifecycle()
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) } 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. // The connection outlives this screen: the home screen's player is the same one.
LaunchedEffect(app) { CarServices.openMedia(app) } LaunchedEffect(app) { CarServices.openMedia(app) }
@@ -128,6 +131,19 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
return@Surface 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 // 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 // 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. // 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("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } 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)) Spacer(Modifier.height(12.dp))
Box(Modifier.weight(1f).fillMaxWidth()) { Box(Modifier.weight(1f).fillMaxWidth()) {
when (tab) { when (tab) {
@@ -192,6 +219,61 @@ private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier =
private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS } 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. */ /** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */
@Composable @Composable
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) { 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. * [onClear] adds a clear button once something is typed; [searchIcon] is off for sign-in fields.
*/ */
@Composable @Composable
private fun SearchField( internal fun SearchField(
query: String, query: String,
hint: String, hint: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -75,6 +75,9 @@ class NowPlaying(
words.any { a.id.contains(it, ignoreCase = true) || a.name.contains(it, ignoreCase = true) } 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. */ /** Thumbs-down buttons say "like" too ("Dislike"), so those are skipped. */
val likeAction val likeAction
get() = custom.firstOrNull { a -> get() = custom.firstOrNull { a ->
@@ -164,6 +167,13 @@ class CarMedia(private val context: Context) {
private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList()) private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
val queue: StateFlow<List<QueueTrack>> = _queue.asStateFlow() 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. */ /** Shuffle and repeat, as [PlaybackStateCompat]'s modes; [PlaybackStateCompat.SHUFFLE_MODE_INVALID] when unknown. */
class Modes( class Modes(
val shuffle: Int = PlaybackStateCompat.SHUFFLE_MODE_INVALID, 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 metadata?.getRating(MediaMetadata.METADATA_KEY_USER_RATING)?.takeIf { it.isRated }?.hasHeart() ?: false
} else null, } else null,
) )
checkSearch()
} }
// --- browsing ------------------------------------------------------------------------ // --- browsing ------------------------------------------------------------------------
@@ -401,6 +412,50 @@ class CarMedia(private val context: Context) {
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras) fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId) 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. */ /** 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() { fun toggleLike() {
val now = _now.value val now = _now.value
@@ -444,6 +499,7 @@ class CarMedia(private val context: Context) {
_phase.value = Phase.IDLE _phase.value = Phase.IDLE
_now.value = NowPlaying() _now.value = NowPlaying()
_queue.value = emptyList() _queue.value = emptyList()
dismissSearch()
} }
/** /**
@@ -479,5 +535,8 @@ class CarMedia(private val context: Context) {
const val LAUNCH_WAIT_MS = 1_500L const val LAUNCH_WAIT_MS = 1_500L
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */ /** Retries once the app has been opened: a cold start can take a few seconds to publish. */
const val SESSION_RETRIES_LAUNCHED = 16 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
} }
} }