mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
taller car keyboard, type from the phone, real media search
A box on the phone types into the car's text field. Media apps with library search show a list of results to pick from, searching a second after you stop typing.
This commit is contained in:
1 parent
7d197bf021
commit
355eb8bffd
6 files changed
+419
-73
No files matched your search
@@ -27,6 +27,13 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceIn
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
/**
|
||||
* Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the
|
||||
@@ -72,64 +79,77 @@ fun CarKeyboard(
|
||||
onDispose { CarKeyboardVisibility.state.value = false }
|
||||
}
|
||||
|
||||
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
||||
// The same field on the phone, for typing on its own keyboard: see [PhoneTyping].
|
||||
val latestChange by rememberUpdatedState(onTextChange)
|
||||
val latestSubmit by rememberUpdatedState(onSubmit)
|
||||
val latestDismiss by rememberUpdatedState(onDismiss)
|
||||
val phoneField = remember {
|
||||
PhoneTyping.open(text, { latestChange(it) }, { latestSubmit() }, onDismiss?.let { { latestDismiss?.invoke() } })
|
||||
}
|
||||
DisposableEffect(phoneField) { onDispose { PhoneTyping.close(phoneField) } }
|
||||
LaunchedEffect(text) { PhoneTyping.carText(phoneField, text) }
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
||||
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
|
||||
|
||||
CompositionLocalProvider(LocalKeyHeight provides keyHeight) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
rows.dropLast(1).forEach { row ->
|
||||
Column(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
rows.dropLast(1).forEach { row ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
row.forEach { key ->
|
||||
val label = if (shifted && !symbols) key.uppercase() else key
|
||||
Key(label, Modifier.weight(1f)) {
|
||||
onTextChange(text + label)
|
||||
shifted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bottom letter row: shift and backspace bookend it, exactly where a physical
|
||||
// keyboard puts them relative to the letter block, not down beside the space bar.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
row.forEach { key ->
|
||||
if (!symbols) {
|
||||
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.6f)) { shifted = !shifted }
|
||||
}
|
||||
rows.last().forEach { key ->
|
||||
val label = if (shifted && !symbols) key.uppercase() else key
|
||||
Key(label, Modifier.weight(1f)) {
|
||||
onTextChange(text + label)
|
||||
shifted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The bottom letter row: shift and backspace bookend it, exactly where a physical
|
||||
// keyboard puts them relative to the letter block, not down beside the space bar.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
if (!symbols) {
|
||||
Key(if (shifted) "SHIFT" else "shift", Modifier.weight(1.6f)) { shifted = !shifted }
|
||||
}
|
||||
rows.last().forEach { key ->
|
||||
val label = if (shifted && !symbols) key.uppercase() else key
|
||||
Key(label, Modifier.weight(1f)) {
|
||||
onTextChange(text + label)
|
||||
shifted = false
|
||||
Key("⌫", Modifier.weight(1.6f)) {
|
||||
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
|
||||
}
|
||||
}
|
||||
Key("⌫", Modifier.weight(1.6f)) {
|
||||
if (text.isNotEmpty()) onTextChange(text.dropLast(1))
|
||||
}
|
||||
}
|
||||
|
||||
// Control row: no backspace here - it stays with the letters above, not next to the
|
||||
// key most likely to be hit right after it by mistake. Dismiss (when offered) is the
|
||||
// last key, bottom-right corner of the keyboard.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
||||
Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
|
||||
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
|
||||
if (onDismiss != null) {
|
||||
DismissKey(onDismiss, Modifier.weight(1.4f))
|
||||
// Control row: no backspace here - it stays with the letters above, not next to the
|
||||
// key most likely to be hit right after it by mistake. Dismiss (when offered) is the
|
||||
// last key, bottom-right corner of the keyboard.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
||||
Key("space", Modifier.weight(if (onDismiss != null) 3.2f else 4f)) { onTextChange("$text ") }
|
||||
SubmitKey(Modifier.weight(1.6f), submitIcon, submitImage, onSubmit)
|
||||
if (onDismiss != null) {
|
||||
DismissKey(onDismiss, Modifier.weight(1.4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,7 +160,7 @@ fun CarKeyboard(
|
||||
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(KEY_HEIGHT),
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
@@ -163,7 +183,7 @@ private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Uni
|
||||
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(KEY_HEIGHT),
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
@@ -181,7 +201,7 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
|
||||
GsIconBox(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(KEY_HEIGHT),
|
||||
modifier = modifier.height(LocalKeyHeight.current),
|
||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
@@ -199,10 +219,12 @@ private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?,
|
||||
}
|
||||
|
||||
/**
|
||||
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few
|
||||
* results all fit a 480px-tall screen - which is the real constraint here, not the key size.
|
||||
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few results
|
||||
* still fit. A 480-tall screen sets the floor; a taller one (LIVI's 1280x720) gets bigger keys.
|
||||
*/
|
||||
private val KEY_HEIGHT = 34.dp
|
||||
private fun keyHeight(screenHeightDp: Int): Dp = (screenHeightDp * 0.075f).dp.coerceIn(36.dp, 52.dp)
|
||||
|
||||
private val LocalKeyHeight = compositionLocalOf { 36.dp }
|
||||
|
||||
private val LETTER_ROWS = listOf(
|
||||
"qwertyuiop".map { it.toString() },
|
||||
|
||||
@@ -70,6 +70,9 @@ import app.seb3thehacker.gearslip.media.MediaEntry
|
||||
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||
import app.seb3thehacker.gearslip.media.QueueTrack
|
||||
import android.media.session.PlaybackState
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
|
||||
/**
|
||||
* A media app on the car screen: what it is playing and its controls on the left, the tree it
|
||||
@@ -134,7 +137,17 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
||||
if (searching) {
|
||||
SearchPanel(
|
||||
appLabel = app.label,
|
||||
onSearch = { query ->
|
||||
media = media,
|
||||
canPickForYou = now.canSearch,
|
||||
onPlayed = {
|
||||
searching = false
|
||||
tab = Tab.NOW_PLAYING
|
||||
},
|
||||
onOpened = {
|
||||
searching = false
|
||||
tab = Tab.BROWSE
|
||||
},
|
||||
onPickForYou = { query ->
|
||||
media.search(query)
|
||||
searching = false
|
||||
tab = Tab.NOW_PLAYING
|
||||
@@ -167,7 +180,7 @@ 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) {
|
||||
if (now.canSearch || media.canSearchLibrary) {
|
||||
GsIconButton(
|
||||
Icons.Filled.Search,
|
||||
"Search ${app.label}",
|
||||
@@ -225,9 +238,36 @@ private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* Search inside the media app. It asks the app's library for matches half a second after the
|
||||
* driver stops typing and lists them, so the driver picks exactly what plays: a song plays, an
|
||||
* album or artist opens in Browse. The search key hides the keyboard to show more results; the
|
||||
* field brings it back. An app with no library search can still be told to play its own best
|
||||
* match, which is all the panel could do before.
|
||||
*/
|
||||
@Composable
|
||||
private fun SearchPanel(appLabel: String, onSearch: (String) -> Unit, onClose: () -> Unit) {
|
||||
private fun SearchPanel(
|
||||
appLabel: String,
|
||||
media: CarMedia,
|
||||
canPickForYou: Boolean,
|
||||
onPlayed: () -> Unit,
|
||||
onOpened: () -> Unit,
|
||||
onPickForYou: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var typing by remember { mutableStateOf(true) }
|
||||
val results by media.results.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(query) {
|
||||
delay(SEARCH_PAUSE_MS)
|
||||
media.searchLibrary(query)
|
||||
}
|
||||
DisposableEffect(Unit) { onDispose { media.clearResults() } }
|
||||
|
||||
val trimmed = query.trim()
|
||||
val current = results?.takeIf { it.query == trimmed }
|
||||
val unsupported = current?.supported == false
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
@@ -235,25 +275,72 @@ private fun SearchPanel(appLabel: String, onSearch: (String) -> Unit, onClose: (
|
||||
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,
|
||||
SearchField(
|
||||
query,
|
||||
"Search $appLabel",
|
||||
Modifier.weight(1f).clickable { typing = true },
|
||||
onClear = {
|
||||
query = ""
|
||||
typing = true
|
||||
},
|
||||
)
|
||||
}
|
||||
CarKeyboard(
|
||||
text = query,
|
||||
onTextChange = { query = it },
|
||||
onSubmit = { if (query.isNotBlank()) onSearch(query.trim()) },
|
||||
onDismiss = onClose,
|
||||
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||
val entries = current?.entries
|
||||
when {
|
||||
trimmed.isEmpty() -> SearchNote("Type a song, artist, album, or playlist.")
|
||||
unsupported && canPickForYou ->
|
||||
SearchNote("$appLabel can't list results. Press search, and $appLabel picks what to play.")
|
||||
unsupported -> SearchNote("$appLabel can't search from the car.")
|
||||
entries == null -> SearchNote("Searching $appLabel…")
|
||||
entries.isEmpty() -> SearchNote("$appLabel found nothing for \"$trimmed\".")
|
||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||
items(entries) { entry ->
|
||||
EntryRow(entry, media) {
|
||||
if (entry.browsable) {
|
||||
media.select(entry)
|
||||
onOpened()
|
||||
} else {
|
||||
media.playAll(entry)
|
||||
onPlayed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typing) {
|
||||
CarKeyboard(
|
||||
text = query,
|
||||
onTextChange = { query = it },
|
||||
onSubmit = {
|
||||
when {
|
||||
trimmed.isEmpty() -> Unit
|
||||
unsupported && canPickForYou -> onPickForYou(trimmed)
|
||||
else -> typing = false
|
||||
}
|
||||
},
|
||||
onDismiss = { if (trimmed.isEmpty()) onClose() else typing = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchNote(text: String) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** How long typing has to pause before the library is searched. */
|
||||
private const val SEARCH_PAUSE_MS = 1_000L
|
||||
|
||||
/** 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) {
|
||||
@@ -572,13 +659,13 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryRow(entry: MediaEntry, media: CarMedia) {
|
||||
private fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit = { media.select(entry) }) {
|
||||
val context = LocalContext.current
|
||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||
}
|
||||
Surface(
|
||||
onClick = { media.select(entry) },
|
||||
onClick = onClick,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package app.seb3thehacker.gearslip.car
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* The text field the car keyboard is typing into, shared with the phone so a passenger can type
|
||||
* on the phone's own keyboard instead. Whichever [CarKeyboard] is on screen registers here; the
|
||||
* phone app shows the same text while one is open, and its edits go back through the car field's
|
||||
* own callbacks, so the car screen can't tell which keyboard typed.
|
||||
*/
|
||||
object PhoneTyping {
|
||||
|
||||
class Field internal constructor(
|
||||
val id: Int,
|
||||
val text: String,
|
||||
internal val onTextChange: (String) -> Unit,
|
||||
internal val onSubmit: () -> Unit,
|
||||
internal val onDismiss: (() -> Unit)?,
|
||||
)
|
||||
|
||||
private val fieldFlow = MutableStateFlow<Field?>(null)
|
||||
|
||||
/** The car's open field, or null when no car keyboard is showing. */
|
||||
val field: StateFlow<Field?> = fieldFlow.asStateFlow()
|
||||
|
||||
private var nextId = 0
|
||||
|
||||
internal fun open(text: String, onTextChange: (String) -> Unit, onSubmit: () -> Unit, onDismiss: (() -> Unit)?): Int {
|
||||
val id = ++nextId
|
||||
fieldFlow.value = Field(id, text, onTextChange, onSubmit, onDismiss)
|
||||
return id
|
||||
}
|
||||
|
||||
/** The car field's text changed, from either keyboard. */
|
||||
internal fun carText(id: Int, text: String) {
|
||||
val f = fieldFlow.value ?: return
|
||||
if (f.id == id && f.text != text) fieldFlow.value = Field(id, text, f.onTextChange, f.onSubmit, f.onDismiss)
|
||||
}
|
||||
|
||||
internal fun close(id: Int) {
|
||||
if (fieldFlow.value?.id == id) fieldFlow.value = null
|
||||
}
|
||||
|
||||
/** From the phone: typed text goes to the car field as if typed there. */
|
||||
fun type(text: String) {
|
||||
fieldFlow.value?.onTextChange?.invoke(text)
|
||||
}
|
||||
|
||||
fun submit() {
|
||||
fieldFlow.value?.onSubmit?.invoke()
|
||||
}
|
||||
|
||||
/** Closes the car keyboard where it can be closed; elsewhere only the phone's field goes. */
|
||||
fun dismiss() {
|
||||
val f = fieldFlow.value ?: return
|
||||
f.onDismiss?.invoke() ?: close(f.id)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaMetadata
|
||||
import android.media.Rating
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
@@ -170,6 +171,17 @@ class CarMedia(private val context: Context) {
|
||||
/** 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)
|
||||
|
||||
/**
|
||||
* What the app's own library search found for [query]. [entries] is null while it looks.
|
||||
* [supported] is false when the app has no library search, so the panel can fall back to
|
||||
* "play the best match" (see [search]).
|
||||
*/
|
||||
class Results(val query: String, val entries: List<MediaEntry>? = null, val supported: Boolean = true)
|
||||
|
||||
private val _results = MutableStateFlow<Results?>(null)
|
||||
val results: StateFlow<Results?> = _results.asStateFlow()
|
||||
private var resultsQuery: String? = null
|
||||
|
||||
private val _search = MutableStateFlow<Search?>(null)
|
||||
val search: StateFlow<Search?> = _search.asStateFlow()
|
||||
private var searchBefore: NowPlaying? = null
|
||||
@@ -286,6 +298,7 @@ class CarMedia(private val context: Context) {
|
||||
|
||||
private fun onConnected(app: MediaApp) {
|
||||
val mb = browser ?: return
|
||||
searchComponent = app.component
|
||||
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
||||
attach(MediaController(context, mb.sessionToken))
|
||||
rootId = mb.root
|
||||
@@ -423,11 +436,102 @@ class CarMedia(private val context: Context) {
|
||||
main.removeCallbacks(searchTimeout)
|
||||
searchBefore = _now.value
|
||||
_search.value = Search(query)
|
||||
GearslipLog.i("media: -> playFromSearch(\"$query\")")
|
||||
GearslipLog.i("media: -> playFromSearch")
|
||||
c.transportControls.playFromSearch(query, Bundle())
|
||||
main.postDelayed(searchTimeout, SEARCH_WAIT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the app's library for matches to [query], to list for the driver to pick from. Unlike
|
||||
* [search], nothing plays until one is chosen, so a wrong guess costs nothing. Metrolist and
|
||||
* ViVi answer with songs from the library first, then YouTube Music.
|
||||
*/
|
||||
fun searchLibrary(query: String) {
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) {
|
||||
clearResults()
|
||||
return
|
||||
}
|
||||
val component = searchComponent
|
||||
if (component == null) {
|
||||
_results.value = Results(q, emptyList(), supported = false)
|
||||
return
|
||||
}
|
||||
resultsQuery = q
|
||||
_results.value = Results(q)
|
||||
val sb = searchBrowser
|
||||
if (sb != null && sb.isConnected) {
|
||||
runSearch(sb, q)
|
||||
return
|
||||
}
|
||||
if (sb != null) return // still connecting; it searches for [resultsQuery] once connected
|
||||
// The framework MediaBrowser has no search; the support library's does, over the same
|
||||
// service. Opened on first use only, so apps nobody searches never see a second client.
|
||||
lateinit var created: MediaBrowserCompat
|
||||
created = MediaBrowserCompat(context, component, object : MediaBrowserCompat.ConnectionCallback() {
|
||||
override fun onConnected() {
|
||||
resultsQuery?.let { runSearch(created, it) }
|
||||
}
|
||||
|
||||
override fun onConnectionFailed() {
|
||||
GearslipLog.w("media: no library connection for search")
|
||||
searchBrowser = null
|
||||
resultsQuery?.let { _results.value = Results(it, emptyList(), supported = false) }
|
||||
}
|
||||
}, null)
|
||||
searchBrowser = created
|
||||
runCatching { created.connect() }.onFailure {
|
||||
searchBrowser = null
|
||||
_results.value = Results(q, emptyList(), supported = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runSearch(sb: MediaBrowserCompat, q: String) {
|
||||
runCatching {
|
||||
sb.search(q, Bundle(), object : MediaBrowserCompat.SearchCallback() {
|
||||
override fun onSearchResult(query: String, extras: Bundle?, items: List<MediaBrowserCompat.MediaItem>) {
|
||||
if (query != resultsQuery) return
|
||||
GearslipLog.i("media: library search found ${items.size}")
|
||||
_results.value = Results(query, items.map(::entryOfCompat))
|
||||
}
|
||||
|
||||
override fun onError(query: String, extras: Bundle?) {
|
||||
if (query != resultsQuery) return
|
||||
GearslipLog.i("media: the app has no library search")
|
||||
_results.value = Results(query, emptyList(), supported = false)
|
||||
}
|
||||
})
|
||||
}.onFailure {
|
||||
GearslipLog.w("media: library search failed: ${it.message}")
|
||||
_results.value = Results(q, emptyList(), supported = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun entryOfCompat(item: MediaBrowserCompat.MediaItem): MediaEntry {
|
||||
val d = item.description
|
||||
return MediaEntry(
|
||||
id = item.mediaId.orEmpty(),
|
||||
title = d.title?.toString().orEmpty(),
|
||||
subtitle = d.subtitle?.toString().orEmpty(),
|
||||
iconUri = d.iconUri,
|
||||
iconBitmap = d.iconBitmap,
|
||||
browsable = item.isBrowsable,
|
||||
playable = item.isPlayable,
|
||||
)
|
||||
}
|
||||
|
||||
/** The connected app's service, for the search connection; null when nothing is connected. */
|
||||
private var searchComponent: android.content.ComponentName? = null
|
||||
private var searchBrowser: MediaBrowserCompat? = null
|
||||
|
||||
fun clearResults() {
|
||||
resultsQuery = null
|
||||
_results.value = null
|
||||
}
|
||||
|
||||
/** Whether there's a library to search through, the better of the two searches. */
|
||||
val canSearchLibrary: Boolean get() = browser?.isConnected == true && searchComponent != null
|
||||
|
||||
fun dismissSearch() {
|
||||
main.removeCallbacks(searchTimeout)
|
||||
main.removeCallbacks(clearSearch)
|
||||
@@ -437,7 +541,7 @@ class CarMedia(private val context: Context) {
|
||||
private val searchTimeout = Runnable {
|
||||
val pending = _search.value ?: return@Runnable
|
||||
if (pending.ignored) return@Runnable
|
||||
GearslipLog.w("media: nothing new played for \"${pending.query}\"")
|
||||
GearslipLog.w("media: nothing new played after a search")
|
||||
_search.value = Search(pending.query, ignored = true)
|
||||
main.postDelayed(clearSearch, SEARCH_NOTICE_MS)
|
||||
}
|
||||
@@ -451,7 +555,7 @@ class CarMedia(private val context: Context) {
|
||||
val now = _now.value
|
||||
if (pending.ignored || !now.playing) return
|
||||
if (now.title != before.title || !before.playing) {
|
||||
GearslipLog.i("media: search played \"${now.title}\"")
|
||||
GearslipLog.i("media: search started playback")
|
||||
dismissSearch()
|
||||
}
|
||||
}
|
||||
@@ -486,6 +590,10 @@ class CarMedia(private val context: Context) {
|
||||
|
||||
fun disconnect() {
|
||||
connecting = null
|
||||
runCatching { searchBrowser?.disconnect() }
|
||||
searchBrowser = null
|
||||
searchComponent = null
|
||||
clearResults()
|
||||
main.removeCallbacksAndMessages(null)
|
||||
runCatching { compat?.unregisterCallback(compatCallback) }
|
||||
compat = null
|
||||
|
||||
@@ -95,21 +95,21 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
onOpenSettings = { screen = Screen.SETTINGS },
|
||||
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
||||
onOpenHelp = { screen = Screen.HELP },
|
||||
onOpenWhatsNew = { screen = Screen.WHATS_NEW },
|
||||
onDisconnect = onDisconnect,
|
||||
)
|
||||
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.SETTINGS })
|
||||
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.SETTINGS -> SettingsScreen(
|
||||
onBack = { screen = Screen.HOME },
|
||||
onOpenRecommendedApps = { screen = Screen.RECOMMENDED_APPS },
|
||||
onOpenWhatsNew = { screen = Screen.WHATS_NEW },
|
||||
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
||||
)
|
||||
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
|
||||
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
|
||||
Screen.HELP -> HelpScreen(
|
||||
onBack = { screen = Screen.HOME },
|
||||
onRequestCallScreening = onRequestCallScreening,
|
||||
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
||||
)
|
||||
Screen.CERT_SETUP -> CertSetupScreen(
|
||||
onDone = {
|
||||
@@ -122,6 +122,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||
)
|
||||
}
|
||||
}
|
||||
// A car keyboard is open: offer the phone's own, for a passenger.
|
||||
if (screen != Screen.CAR_PREVIEW) PhoneTypingBar()
|
||||
}
|
||||
|
||||
CompatWarning()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package app.seb3thehacker.gearslip.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.seb3thehacker.gearslip.car.PhoneTyping
|
||||
|
||||
/**
|
||||
* While the car screen shows its keyboard, the phone shows the same field with its own keyboard
|
||||
* up, so a passenger can type comfortably. Each keystroke lands on the car screen at once.
|
||||
*/
|
||||
@Composable
|
||||
fun PhoneTypingBar(modifier: Modifier = Modifier) {
|
||||
val field by PhoneTyping.field.collectAsState()
|
||||
val open = field ?: return
|
||||
var value by remember(open.id) { mutableStateOf(TextFieldValue(open.text, TextRange(open.text.length))) }
|
||||
// The car keyboard typed too: take its text, cursor at the end.
|
||||
if (open.text != value.text) value = TextFieldValue(open.text, TextRange(open.text.length))
|
||||
val focus = remember { FocusRequester() }
|
||||
LaunchedEffect(open.id) { runCatching { focus.requestFocus() } }
|
||||
|
||||
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.fillMaxWidth().imePadding()) {
|
||||
Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = {
|
||||
value = it
|
||||
PhoneTyping.type(it.text)
|
||||
},
|
||||
label = { Text("Type for the car screen") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
keyboardActions = KeyboardActions(onSearch = { PhoneTyping.submit() }),
|
||||
modifier = Modifier.weight(1f).focusRequester(focus),
|
||||
)
|
||||
IconButton(onClick = PhoneTyping::dismiss) {
|
||||
Icon(Icons.Filled.Close, contentDescription = "Close the car keyboard")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user