mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
song search
This commit is contained in:
1 parent
07d164ac74
commit
913e4af133
4 files changed
+143
-2
No files matched your search
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user