mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-11 08:12:26 +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
+374
-28
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.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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
|
* Whether a [CarKeyboard] is on screen right now, so [CarUi] can give it the row of height the
|
||||||
@@ -72,8 +79,20 @@ fun CarKeyboard(
|
|||||||
onDispose { CarKeyboardVisibility.state.value = false }
|
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) }
|
||||||
|
|
||||||
|
val rows = if (symbols) SYMBOL_ROWS else LETTER_ROWS
|
||||||
|
val keyHeight = keyHeight(LocalConfiguration.current.screenHeightDp)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalKeyHeight provides keyHeight) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
@@ -134,13 +153,14 @@ fun CarKeyboard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
private fun Key(label: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.height(KEY_HEIGHT),
|
modifier = modifier.height(LocalKeyHeight.current),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
||||||
shape = RoundedCornerShape(8.dp),
|
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) {
|
private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.height(KEY_HEIGHT),
|
modifier = modifier.height(LocalKeyHeight.current),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
|
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||||
shape = RoundedCornerShape(8.dp),
|
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) {
|
private fun SubmitKey(modifier: Modifier, search: Boolean, image: ImageVector?, onClick: () -> Unit) {
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier.height(KEY_HEIGHT),
|
modifier = modifier.height(LocalKeyHeight.current),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
|
||||||
shape = RoundedCornerShape(8.dp),
|
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
|
* Big enough to hit on a bumpy road, small enough that four rows plus a field plus a few results
|
||||||
* results all fit a 480px-tall screen - which is the real constraint here, not the key size.
|
* 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(
|
private val LETTER_ROWS = listOf(
|
||||||
"qwertyuiop".map { it.toString() },
|
"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.NowPlaying
|
||||||
import app.seb3thehacker.gearslip.media.QueueTrack
|
import app.seb3thehacker.gearslip.media.QueueTrack
|
||||||
import android.media.session.PlaybackState
|
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
|
* 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) {
|
if (searching) {
|
||||||
SearchPanel(
|
SearchPanel(
|
||||||
appLabel = app.label,
|
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)
|
media.search(query)
|
||||||
searching = false
|
searching = false
|
||||||
tab = Tab.NOW_PLAYING
|
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("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) {
|
if (now.canSearch || media.canSearchLibrary) {
|
||||||
GsIconButton(
|
GsIconButton(
|
||||||
Icons.Filled.Search,
|
Icons.Filled.Search,
|
||||||
"Search ${app.label}",
|
"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
|
* 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.
|
* 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
|
@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 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()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
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),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
GsIconButton(Icons.Filled.ArrowBack, "Back", onClose, size = ICON_BUTTON_SIZE)
|
GsIconButton(Icons.Filled.ArrowBack, "Back", onClose, size = ICON_BUTTON_SIZE)
|
||||||
SearchField(query, "Search $appLabel", Modifier.weight(1f), onClear = { query = "" })
|
SearchField(
|
||||||
|
query,
|
||||||
|
"Search $appLabel",
|
||||||
|
Modifier.weight(1f).clickable { typing = true },
|
||||||
|
onClear = {
|
||||||
|
query = ""
|
||||||
|
typing = true
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 24.dp), contentAlignment = Alignment.Center) {
|
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(
|
||||||
"Type a song, artist, album, or playlist. $appLabel picks what to play.",
|
text,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CarKeyboard(
|
|
||||||
text = query,
|
|
||||||
onTextChange = { query = it },
|
|
||||||
onSubmit = { if (query.isNotBlank()) onSearch(query.trim()) },
|
|
||||||
onDismiss = onClose,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** A line under the tabs while the app works on a search, and if it plays nothing for it. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: () -> Unit) {
|
private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: () -> Unit) {
|
||||||
@@ -572,13 +659,13 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 context = LocalContext.current
|
||||||
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
val icon by produceState(entry.iconBitmap, entry.iconUri) {
|
||||||
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
value = entry.iconBitmap ?: MediaArt.load(context, entry.iconUri)
|
||||||
}
|
}
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { media.select(entry) },
|
onClick = onClick,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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.graphics.Bitmap
|
||||||
import android.media.MediaMetadata
|
import android.media.MediaMetadata
|
||||||
import android.media.Rating
|
import android.media.Rating
|
||||||
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
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. */
|
/** 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)
|
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)
|
private val _search = MutableStateFlow<Search?>(null)
|
||||||
val search: StateFlow<Search?> = _search.asStateFlow()
|
val search: StateFlow<Search?> = _search.asStateFlow()
|
||||||
private var searchBefore: NowPlaying? = null
|
private var searchBefore: NowPlaying? = null
|
||||||
@@ -286,6 +298,7 @@ class CarMedia(private val context: Context) {
|
|||||||
|
|
||||||
private fun onConnected(app: MediaApp) {
|
private fun onConnected(app: MediaApp) {
|
||||||
val mb = browser ?: return
|
val mb = browser ?: return
|
||||||
|
searchComponent = app.component
|
||||||
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
GearslipLog.i("media: connected to ${app.label}, root=${mb.root}")
|
||||||
attach(MediaController(context, mb.sessionToken))
|
attach(MediaController(context, mb.sessionToken))
|
||||||
rootId = mb.root
|
rootId = mb.root
|
||||||
@@ -423,11 +436,102 @@ class CarMedia(private val context: Context) {
|
|||||||
main.removeCallbacks(searchTimeout)
|
main.removeCallbacks(searchTimeout)
|
||||||
searchBefore = _now.value
|
searchBefore = _now.value
|
||||||
_search.value = Search(query)
|
_search.value = Search(query)
|
||||||
GearslipLog.i("media: -> playFromSearch(\"$query\")")
|
GearslipLog.i("media: -> playFromSearch")
|
||||||
c.transportControls.playFromSearch(query, Bundle())
|
c.transportControls.playFromSearch(query, Bundle())
|
||||||
main.postDelayed(searchTimeout, SEARCH_WAIT_MS)
|
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() {
|
fun dismissSearch() {
|
||||||
main.removeCallbacks(searchTimeout)
|
main.removeCallbacks(searchTimeout)
|
||||||
main.removeCallbacks(clearSearch)
|
main.removeCallbacks(clearSearch)
|
||||||
@@ -437,7 +541,7 @@ class CarMedia(private val context: Context) {
|
|||||||
private val searchTimeout = Runnable {
|
private val searchTimeout = Runnable {
|
||||||
val pending = _search.value ?: return@Runnable
|
val pending = _search.value ?: return@Runnable
|
||||||
if (pending.ignored) 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)
|
_search.value = Search(pending.query, ignored = true)
|
||||||
main.postDelayed(clearSearch, SEARCH_NOTICE_MS)
|
main.postDelayed(clearSearch, SEARCH_NOTICE_MS)
|
||||||
}
|
}
|
||||||
@@ -451,7 +555,7 @@ class CarMedia(private val context: Context) {
|
|||||||
val now = _now.value
|
val now = _now.value
|
||||||
if (pending.ignored || !now.playing) return
|
if (pending.ignored || !now.playing) return
|
||||||
if (now.title != before.title || !before.playing) {
|
if (now.title != before.title || !before.playing) {
|
||||||
GearslipLog.i("media: search played \"${now.title}\"")
|
GearslipLog.i("media: search started playback")
|
||||||
dismissSearch()
|
dismissSearch()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,6 +590,10 @@ class CarMedia(private val context: Context) {
|
|||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
connecting = null
|
connecting = null
|
||||||
|
runCatching { searchBrowser?.disconnect() }
|
||||||
|
searchBrowser = null
|
||||||
|
searchComponent = null
|
||||||
|
clearResults()
|
||||||
main.removeCallbacksAndMessages(null)
|
main.removeCallbacksAndMessages(null)
|
||||||
runCatching { compat?.unregisterCallback(compatCallback) }
|
runCatching { compat?.unregisterCallback(compatCallback) }
|
||||||
compat = null
|
compat = null
|
||||||
|
|||||||
@@ -95,21 +95,21 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
|||||||
onOpenSettings = { screen = Screen.SETTINGS },
|
onOpenSettings = { screen = Screen.SETTINGS },
|
||||||
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
||||||
onOpenHelp = { screen = Screen.HELP },
|
onOpenHelp = { screen = Screen.HELP },
|
||||||
onOpenWhatsNew = { screen = Screen.WHATS_NEW },
|
|
||||||
onDisconnect = onDisconnect,
|
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.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
|
||||||
Screen.SETTINGS -> SettingsScreen(
|
Screen.SETTINGS -> SettingsScreen(
|
||||||
onBack = { screen = Screen.HOME },
|
onBack = { screen = Screen.HOME },
|
||||||
onOpenRecommendedApps = { screen = Screen.RECOMMENDED_APPS },
|
onOpenRecommendedApps = { screen = Screen.RECOMMENDED_APPS },
|
||||||
|
onOpenWhatsNew = { screen = Screen.WHATS_NEW },
|
||||||
|
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
||||||
)
|
)
|
||||||
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
|
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
|
||||||
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
|
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
|
||||||
Screen.HELP -> HelpScreen(
|
Screen.HELP -> HelpScreen(
|
||||||
onBack = { screen = Screen.HOME },
|
onBack = { screen = Screen.HOME },
|
||||||
onRequestCallScreening = onRequestCallScreening,
|
onRequestCallScreening = onRequestCallScreening,
|
||||||
onReplayTutorial = { screen = Screen.SETUP_GUIDE },
|
|
||||||
)
|
)
|
||||||
Screen.CERT_SETUP -> CertSetupScreen(
|
Screen.CERT_SETUP -> CertSetupScreen(
|
||||||
onDone = {
|
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()
|
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