mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 00:12:24 +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
|
# 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user