From 355eb8bffd7d8da25c95c7b681240e4a86000062 Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Mon, 5 Oct 2026 01:21:16 -0700 Subject: [PATCH] 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. --- .../seb3thehacker/gearslip/car/CarKeyboard.kt | 120 ++++++++++------- .../seb3thehacker/gearslip/car/MediaScreen.kt | 123 +++++++++++++++--- .../seb3thehacker/gearslip/car/PhoneTyping.kt | 60 +++++++++ .../seb3thehacker/gearslip/media/CarMedia.kt | 114 +++++++++++++++- .../seb3thehacker/gearslip/ui/GearslipApp.kt | 8 +- .../gearslip/ui/PhoneTypingBar.kt | 67 ++++++++++ 6 files changed, 419 insertions(+), 73 deletions(-) create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneTyping.kt create mode 100644 gearslip/src/main/java/app/seb3thehacker/gearslip/ui/PhoneTypingBar.kt diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt index fc06773..0dfa2fc 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarKeyboard.kt @@ -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() }, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index dfcfe01..97168f7 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -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(), diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneTyping.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneTyping.kt new file mode 100644 index 0000000..6d5a5d6 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneTyping.kt @@ -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(null) + + /** The car's open field, or null when no car keyboard is showing. */ + val field: StateFlow = 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) + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt index 9536d19..056621d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt @@ -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? = null, val supported: Boolean = true) + + private val _results = MutableStateFlow(null) + val results: StateFlow = _results.asStateFlow() + private var resultsQuery: String? = null + private val _search = MutableStateFlow(null) val search: StateFlow = _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) { + 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 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt index b53c99f..e1a341c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -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() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/PhoneTypingBar.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/PhoneTypingBar.kt new file mode 100644 index 0000000..c64db70 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/PhoneTypingBar.kt @@ -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") + } + } + } +}