EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

OnScreenKeyboard.kt

594 lines · 25.5 KB

Changed in: 0.5.0

1package dev.stave.ui.components
2
3import androidx.compose.foundation.background
4import androidx.compose.foundation.border
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.interaction.MutableInteractionSource
7import androidx.compose.foundation.interaction.collectIsFocusedAsState
8import androidx.compose.foundation.layout.Arrangement
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.Row
12import androidx.compose.foundation.layout.fillMaxHeight
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.height
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.size
17import androidx.compose.foundation.layout.width
18import androidx.compose.foundation.shape.RoundedCornerShape
19import androidx.compose.material.icons.Icons
20import androidx.compose.material.icons.filled.Backspace
21import androidx.compose.material.icons.filled.Check
22import androidx.compose.material.icons.filled.Mic
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.LaunchedEffect
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.remember
27import androidx.compose.ui.Alignment
28import androidx.compose.ui.Modifier
29import androidx.compose.ui.draw.clip
30import androidx.compose.ui.focus.FocusRequester
31import androidx.compose.ui.focus.focusRequester
32import androidx.compose.ui.input.key.Key
33import androidx.compose.ui.input.key.KeyEventType
34import androidx.compose.ui.input.key.key
35import androidx.compose.ui.input.key.onPreviewKeyEvent
36import androidx.compose.ui.input.key.type
37import androidx.compose.ui.platform.LocalContext
38import androidx.compose.ui.unit.dp
39import androidx.tv.material3.Icon
40import androidx.tv.material3.MaterialTheme
41import androidx.tv.material3.Text
42import dev.stave.ui.theme.AccentAmber
43import dev.stave.ui.theme.OnMuted
44import dev.stave.ui.theme.OnPrimary
45import dev.stave.ui.theme.SurfaceRaised
46import dev.stave.ui.theme.loadFraunces
47import dev.stave.ui.theme.staveInterFamily
48import dev.stave.ui.theme.withInterDigits
49
50/**
51 * On-screen D-pad-navigable keyboard. Designed as a drop-in replacement for the
52 * Android system IME on TV, inspired by Kodi's Copacetic skin.
53 *
54 * Why a custom keyboard rather than the system IME:
55 * - The Shield's system keyboard takes over the screen and is hard to
56 * dismiss; it's a usability cliff in a D-pad flow.
57 * - Every serious TV media app (Kodi, Plex, Jellyfin) rolls its own for
58 * this reason.
59 * - Having our own means we can do live narrowing next to the keys, style
60 * it to match the app, and never have IME-reappears bugs.
61 *
62 * Layout principles:
63 * - QWERTY (not alphabetical) — people find letters via muscle memory.
64 * - Flat buttons. Focused key shows an amber background tint; no borders.
65 * - 3 alphabet rows (10 / 9 / 7) + 1 function row with Shift, Space,
66 * Backspace, Mic (conditional), Done.
67 * - Single view with a Shift key that cycles: lower → Upper → 123 → lower.
68 * A small indicator under Shift shows the current layer.
69 *
70 * Focus policy:
71 * - On first composition the keyboard grabs focus on the top-left key (Q
72 * in the lowercase layout).
73 * - Compose's spatial focus handles all internal navigation.
74 * - Keys in column 0 of each row wrap a [FocusRequester] that the parent
75 * can observe via [onLeftFromColumn0]; when the user presses Left there,
76 * we invoke the callback so the parent can route focus out of the
77 * keyboard (to the Now Playing bar or the Earhorn button).
78 *
79 * Voice: the Mic key invokes [onVoice] which the parent wires to
80 * [VoiceSearchDialog]. The dialog delegates to the system's RecognizerIntent
81 * — the same path the system keyboard's mic button uses — because trying to
82 * run the in-process SpeechRecognizer on TV builds was unreliable: Shield
83 * TV in particular doesn't ship with an on-device recognition model, and
84 * the third-party RecognitionService binding fails silently during audio
85 * capture there. The system intent always works wherever the system
86 * keyboard's mic does, which is the user's working baseline.
87 * [voiceAvailable] controls whether the key renders at all.
88 */
89@Composable
90fun OnScreenKeyboard(
91 query: String,
92 onKey: (String) -> Unit,
93 onBackspace: () -> Unit,
94 /** Invoked when the user taps the Clear key. Parent wipes the
95 * entire query in one go (vs. Backspace which removes one char).
96 * Separate callback rather than onBackspace-with-length so the
97 * parent can decide what "clear" means for its search state
98 * (e.g. also reset live-search, dismiss debounced pending calls). */
99 onClear: () -> Unit,
100 onDone: () -> Unit,
101 /** Invoked when the user taps the mic key. Parent opens its own voice
102 * dialog in response (we don't own the dialog here — keyboard stays
103 * pure UI, dialog lifecycle belongs to the owning screen so search
104 * state mutation / navigation still happen in the right place). */
105 onVoice: () -> Unit,
106 /** When false, the mic key is hidden from the function row. Device
107 * has no on-device recognizer available. */
108 voiceAvailable: Boolean,
109 /** Invoked when the user presses D-pad Up from a key in the TOP row
110 * (number row). This is the natural "exit the keyboard" gesture:
111 * parent closes the search session and parks focus back where search
112 * was launched from (typically the Earhorn button). Inner rows pass Up
113 * through to spatial focus for normal row-to-row navigation. */
114 onUpEscape: () -> Unit,
115 /** Attached to the Q key (top-left of the letter rows). Hoisted out
116 * of the keyboard so the parent can route focus TO Q from elsewhere
117 * — e.g. Left-from-first-recent-search should land on Q as if the
118 * user had just walked in. Passed by the parent rather than created
119 * inside; parent's lifetime outlives individual keyboard compositions. */
120 firstKeyFocus: FocusRequester,
121 /** When true (default), the keyboard requests focus on Q on first
122 * composition. Parent sets this false when it wants initial focus
123 * elsewhere (e.g. on the recents list, so the user walks INTO the
124 * keyboard from the right rather than the keyboard grabbing focus
125 * on open). */
126 autoFocusOnEnter: Boolean = true,
127 modifier: Modifier = Modifier,
128) {
129 val ctx = LocalContext.current
130 // Conditionally claim focus on first composition. When the parent has
131 // chosen to seat focus elsewhere (typically the recents list), skip so
132 // we don't fight the parent's own focus request.
133 LaunchedEffect(Unit) {
134 if (autoFocusOnEnter) {
135 runCatching { firstKeyFocus.requestFocus() }
136 }
137 }
138
139 // Hardware-remote shortcuts caught at the top-level column so they fire
140 // regardless of focused key.
141 // Play/Pause = Space
142 // Rewind / SkipBackward = Backspace
143 val hardwareKeys = Modifier.onPreviewKeyEvent { ev ->
144 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
145 when (ev.key) {
146 Key.MediaPlayPause, Key.MediaPlay, Key.MediaPause -> {
147 onKey(" "); true
148 }
149 Key.MediaRewind, Key.MediaSkipBackward -> {
150 onBackspace(); true
151 }
152 else -> false
153 }
154 }
155
156 Column(
157 modifier = modifier.then(hardwareKeys),
158 verticalArrangement = Arrangement.spacedBy(6.dp),
159 ) {
160 // Query display — typed text with an amber caret. Not focusable.
161 Box(
162 Modifier
163 .fillMaxWidth()
164 .background(SurfaceRaised)
165 .padding(horizontal = 16.dp, vertical = 14.dp),
166 ) {
167 if (query.isEmpty()) {
168 // Just the amber caret — the pill in the header bar
169 // already shows "Search…", so repeating it here doubles up.
170 Box(
171 Modifier
172 .padding(start = 2.dp)
173 .size(width = 2.dp, height = 18.dp)
174 .background(AccentAmber)
175 )
176 } else {
177 Row(verticalAlignment = Alignment.CenterVertically) {
178 Text(
179 withInterDigits(query),
180 style = MaterialTheme.typography.bodyLarge,
181 color = OnPrimary,
182 )
183 Box(
184 Modifier
185 .padding(start = 2.dp)
186 .size(width = 2.dp, height = 18.dp)
187 .background(AccentAmber)
188 )
189 }
190 }
191 }
192
193 // Four character rows (QWERTY letters, home row, bottom row, digits),
194 // each a simple weighted Row (not LazyRow). Rows are 12 keys wide and
195 // fit the keyboard pane without horizontal scrolling. Wrap-around on
196 // both ends of each row.
197 //
198 // Fonts are resolved once here and passed into each key: Fraunces
199 // (opsz=144) for letters and symbols, Inter for digits. Per-key font
200 // selection is a single branch on isDigit inside CharacterKey.
201 val frauncesFamily = remember(ctx) { loadFraunces(ctx) }
202 val interFamily = staveInterFamily()
203 val rows = allRows()
204 val rowFocusRequesters: List<List<FocusRequester>> = remember(rows.size, rows.map { it.keys.size }) {
205 rows.map { r -> List(r.keys.size) { FocusRequester() } }
206 }
207 // Every function key has its own FocusRequester. Down from the
208 // bottom character row routes to whichever key spatially covers
209 // that column (proportional mapping based on the function row's
210 // actual weights), so the user lands where their eye was — no
211 // surprise jumps from a digit on the right to Space on the far
212 // left just because Space is wider.
213 val spaceKeyFocus = remember { FocusRequester() }
214 val backspaceKeyFocus = remember { FocusRequester() }
215 val clearKeyFocus = remember { FocusRequester() }
216 val micKeyFocus = remember { FocusRequester() }
217 val doneKeyFocus = remember { FocusRequester() }
218
219 // Weight order matches the render order in the function row. The
220 // mic slot disappears when voiceAvailable is false.
221 val functionKeyTargets: List<Pair<FocusRequester, Double>> = remember(voiceAvailable) {
222 buildList {
223 add(spaceKeyFocus to if (voiceAvailable) 5.0 else 6.0)
224 add(backspaceKeyFocus to 2.0)
225 add(clearKeyFocus to 2.0)
226 if (voiceAvailable) add(micKeyFocus to 1.2)
227 add(doneKeyFocus to 1.2)
228 }
229 }
230 val functionKeyTotalWeight = remember(functionKeyTargets) {
231 functionKeyTargets.sumOf { it.second }
232 }
233
234 rows.forEachIndexed { rowIdx, row ->
235 CharacterRow(
236 keys = row.keys,
237 firstKeyFocus = if (rowIdx == 0) firstKeyFocus else null,
238 focusRequesters = rowFocusRequesters[rowIdx],
239 frauncesFamily = frauncesFamily,
240 interFamily = interFamily,
241 onKey = onKey,
242 onUpFromCol = { col ->
243 if (rowIdx == 0) {
244 onUpEscape()
245 } else {
246 val target = rowFocusRequesters[rowIdx - 1]
247 runCatching {
248 target[col.coerceIn(0, target.lastIndex)].requestFocus()
249 }
250 }
251 },
252 onDownFromCol = if (rowIdx < rows.lastIndex) {
253 { col ->
254 val target = rowFocusRequesters[rowIdx + 1]
255 runCatching {
256 target[col.coerceIn(0, target.lastIndex)].requestFocus()
257 }
258 }
259 } else {
260 // Bottom char row → pick the function key whose x-range
261 // covers the column. Midpoint of the column (in 0..1)
262 // multiplied by total weight gives a cumulative weight
263 // value; iterate keys accumulating weights and stop at
264 // the first whose running total exceeds it.
265 { col ->
266 val colMidpoint = (col + 0.5) / row.keys.size
267 val weightAtCol = colMidpoint * functionKeyTotalWeight
268 var accum = 0.0
269 var target = functionKeyTargets.last().first
270 for ((fr, w) in functionKeyTargets) {
271 accum += w
272 if (weightAtCol < accum) {
273 target = fr
274 break
275 }
276 }
277 runCatching { target.requestFocus() }
278 }
279 },
280 )
281 }
282
283 // Function row: Space · Backspace · Clear · [Mic] · Done. Clear
284 // wipes the entire query in one press (Backspace is one-char);
285 // placed next to Backspace because they're both deletion actions,
286 // ordered smaller-to-larger scope.
287 // Function row wrapped in a Box that swallows D-pad Down so focus
288 // doesn't escape to whatever is below the keyboard (typically the
289 // Now Playing bar). Exits from the keyboard are deliberate: Up
290 // (top row → close), Back, or Done.
291 Box(
292 Modifier.onPreviewKeyEvent { ev ->
293 if (ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionDown) {
294 true // consume
295 } else false
296 }
297 ) {
298 KeyRow {
299 FunctionKey(
300 label = "space",
301 onClick = { onKey(" ") },
302 onLeftEscape = null,
303 weight = if (voiceAvailable) 5.0f else 6.0f,
304 focusRequester = spaceKeyFocus,
305 )
306 FunctionKey(
307 icon = Icons.Filled.Backspace,
308 contentDescription = "Backspace",
309 onClick = onBackspace,
310 onLeftEscape = null,
311 weight = 2.0f,
312 focusRequester = backspaceKeyFocus,
313 )
314 FunctionKey(
315 label = "clear",
316 onClick = onClear,
317 onLeftEscape = null,
318 weight = 2.0f,
319 focusRequester = clearKeyFocus,
320 )
321 if (voiceAvailable) {
322 FunctionKey(
323 icon = Icons.Filled.Mic,
324 contentDescription = "Voice search",
325 onClick = onVoice,
326 onLeftEscape = null,
327 weight = 1.2f,
328 focusRequester = micKeyFocus,
329 )
330 }
331 FunctionKey(
332 icon = Icons.Filled.Check,
333 contentDescription = "Search",
334 onClick = onDone,
335 onLeftEscape = null,
336 weight = 1.2f,
337 focusRequester = doneKeyFocus,
338 )
339 }
340 }
341 }
342}
343
344// -----------------------------------------------------------------------------
345// Helpers
346// -----------------------------------------------------------------------------
347
348/** One of the four character rows. Font-family is chosen per-character
349 * inside CharacterKey — digits (0-9) render in Inter because Fraunces
350 * digits at key size are hard to scan, everything else (including & @
351 * and all punctuation) stays on Fraunces alongside the letters. */
352private data class CharRow(val keys: List<String>)
353
354private fun allRows(): List<CharRow> = listOf(
355 CharRow(listOf("q","w","e","r","t","y","u","i","o","p","'","\"")),
356 CharRow(listOf("a","s","d","f","g","h","j","k","l","-","_",".")),
357 CharRow(listOf("z","x","c","v","b","n","m","?","!",",","/",":")),
358 CharRow(listOf("1","2","3","4","5","6","7","8","9","0","&","@")),
359)
360
361/** Row container — horizontal flow with consistent spacing between keys and
362 * a fixed height so rows align even when content differs.
363 *
364 * content lambda takes RowScope as its receiver so the child Key/Function
365 * composables (which are RowScope extension functions, using .weight()) can
366 * resolve their receiver. Without the receiver type here, the children
367 * would appear unresolved at the call site. */
368@Composable
369private fun KeyRow(content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) {
370 Row(
371 Modifier
372 .fillMaxWidth()
373 .height(54.dp),
374 horizontalArrangement = Arrangement.spacedBy(6.dp),
375 ) {
376 content()
377 }
378}
379
380/**
381 * Row of character keys in a uniform weighted Row. Every key has weight=1
382 * so the row fills the available width with 12 equal cells. All keys are
383 * always composed — this is the critical reliability property: explicit
384 * cross-row Up/Down focus routing depends on every target FocusRequester
385 * being attached to a real focus node, which LazyRow's virtualization
386 * would break for off-screen items (causing "Up does nothing" and
387 * "sometimes jumps 2 keys over" symptoms).
388 *
389 * Navigation out of the row:
390 * - Left on first key → wrap to last key in this row
391 * - Right on last key → wraps to first key of same row (speed-typing
392 * shortcut — symmetric with the Left-from-first → last wrap)
393 * - Up/Down → invoke [onUpFromCol]/[onDownFromCol] with the key's column
394 * index; parent routes focus to the column-aligned key in adjacent row.
395 */
396@Composable
397private fun CharacterRow(
398 keys: List<String>,
399 firstKeyFocus: FocusRequester?,
400 focusRequesters: List<FocusRequester>,
401 frauncesFamily: androidx.compose.ui.text.font.FontFamily,
402 interFamily: androidx.compose.ui.text.font.FontFamily,
403 onKey: (String) -> Unit,
404 onUpFromCol: (Int) -> Unit,
405 onDownFromCol: ((Int) -> Unit)?,
406) {
407 Row(
408 modifier = Modifier
409 .fillMaxWidth()
410 .height(54.dp),
411 horizontalArrangement = Arrangement.spacedBy(6.dp),
412 ) {
413 keys.forEachIndexed { colIdx, label ->
414 val isFirst = colIdx == 0
415 val isLast = colIdx == keys.lastIndex
416 CharacterKey(
417 label = label,
418 frauncesFamily = frauncesFamily,
419 interFamily = interFamily,
420 onClick = { onKey(label) },
421 focusRequester = if (isFirst && firstKeyFocus != null)
422 firstKeyFocus else focusRequesters[colIdx],
423 // When firstKeyFocus is active on Q (row 0, col 0), also
424 // attach focusRequesters[0] to the same key so cross-row
425 // routing (e.g. Up-from-A) and within-row wrap
426 // (e.g. Right-from-") — which use focusRequesters[0] —
427 // find an attached handle.
428 secondaryFocusRequester = if (isFirst && firstKeyFocus != null)
429 focusRequesters[0] else null,
430 onLeftWrap = if (isFirst) {
431 {
432 runCatching { focusRequesters[keys.lastIndex].requestFocus() }
433 }
434 } else null,
435 onRightWrap = if (isLast) {
436 {
437 runCatching { focusRequesters[0].requestFocus() }
438 }
439 } else null,
440 onUp = { onUpFromCol(colIdx) },
441 onDown = if (onDownFromCol != null) {
442 { onDownFromCol(colIdx) }
443 } else null,
444 )
445 }
446 }
447}
448
449/** Single character key. weight=1 in the parent Row so every row's 12
450 * cells divide the width uniformly. Column alignment is geometric, which
451 * keeps spatial focus and explicit column routing in lockstep.
452 *
453 * Font: non-digit keys use Fraunces (opsz=144), digit keys use Inter.
454 * Families are resolved once in the parent keyboard composable and passed
455 * in — applying fontFamily directly rather than leaning on the theme style
456 * because titleLarge's Fraunces assignment drops off symbol glyphs
457 * (' " - _ . ? ! , / : & @) on some TV Compose builds. */
458@Composable
459private fun androidx.compose.foundation.layout.RowScope.CharacterKey(
460 label: String,
461 frauncesFamily: androidx.compose.ui.text.font.FontFamily,
462 interFamily: androidx.compose.ui.text.font.FontFamily,
463 onClick: () -> Unit,
464 focusRequester: FocusRequester,
465 /** Optional second FocusRequester attached to the same key. Used on
466 * the Q key (row 0, col 0) where BOTH the screen-wide firstKeyFocus
467 * handle AND the row's own focusRequesters[0] need to point at that
468 * key — otherwise the unused one is left unattached and
469 * requestFocus on it is a silent no-op. */
470 secondaryFocusRequester: FocusRequester? = null,
471 onLeftWrap: (() -> Unit)?,
472 onRightWrap: (() -> Unit)?,
473 onUp: () -> Unit,
474 onDown: (() -> Unit)?,
475) {
476 val interaction = remember { MutableInteractionSource() }
477 val focused by interaction.collectIsFocusedAsState()
478 val shape = RoundedCornerShape(4.dp)
479 val isDigit = label.length == 1 && label[0].isDigit()
480 val family = if (isDigit) interFamily else frauncesFamily
481 val textStyle = MaterialTheme.typography.titleLarge.copy(fontFamily = family)
482 Box(
483 Modifier
484 .weight(1f)
485 .fillMaxHeight()
486 .clip(shape)
487 .background(SurfaceRaised)
488 .then(
489 if (focused) Modifier.border(1.5.dp, AccentAmber, shape)
490 else Modifier
491 )
492 .focusRequester(focusRequester)
493 .then(
494 if (secondaryFocusRequester != null)
495 Modifier.focusRequester(secondaryFocusRequester)
496 else Modifier
497 )
498 .onPreviewKeyEvent { ev ->
499 if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
500 when (ev.key) {
501 Key.DirectionLeft -> {
502 if (onLeftWrap != null) { onLeftWrap(); true }
503 else false
504 }
505 Key.DirectionRight -> {
506 if (onRightWrap != null) { onRightWrap(); true }
507 else false
508 }
509 Key.DirectionUp -> {
510 onUp(); true
511 }
512 Key.DirectionDown -> {
513 if (onDown != null) { onDown(); true }
514 else false
515 }
516 else -> false
517 }
518 }
519 .clickable(interaction, indication = null) { onClick() },
520 contentAlignment = Alignment.Center,
521 ) {
522 Text(
523 label,
524 style = textStyle,
525 color = OnPrimary,
526 )
527 }
528}
529
530/** Function key — wider, can carry either a text label OR an icon, with an
531 * optional secondary hint line (e.g. "⇧" under Shift). */
532@Composable
533private fun androidx.compose.foundation.layout.RowScope.FunctionKey(
534 label: String? = null,
535 hint: String? = null,
536 icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
537 contentDescription: String? = null,
538 onClick: () -> Unit,
539 onLeftEscape: (() -> Unit)?,
540 weight: Float,
541 focusRequester: FocusRequester? = null,
542) {
543 val interaction = remember { MutableInteractionSource() }
544 val focused by interaction.collectIsFocusedAsState()
545 val shape = RoundedCornerShape(4.dp)
546 Box(
547 Modifier
548 .weight(weight)
549 .fillMaxHeight()
550 .clip(shape)
551 .background(SurfaceRaised)
552 .then(
553 if (focused) Modifier.border(1.5.dp, AccentAmber, shape)
554 else Modifier
555 )
556 .then(
557 if (focusRequester != null) Modifier.focusRequester(focusRequester)
558 else Modifier
559 )
560 .then(
561 if (onLeftEscape != null) Modifier.onPreviewKeyEvent { ev ->
562 if (ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionLeft) {
563 onLeftEscape()
564 true
565 } else false
566 } else Modifier
567 )
568 .clickable(interaction, indication = null) { onClick() },
569 contentAlignment = Alignment.Center,
570 ) {
571 when {
572 icon != null -> Icon(
573 icon,
574 contentDescription = contentDescription,
575 tint = OnPrimary,
576 modifier = Modifier.size(22.dp),
577 )
578 label != null -> Column(horizontalAlignment = Alignment.CenterHorizontally) {
579 Text(
580 label,
581 style = MaterialTheme.typography.labelLarge,
582 color = OnPrimary,
583 )
584 if (hint != null) {
585 Text(
586 hint,
587 style = MaterialTheme.typography.labelSmall,
588 color = OnMuted,
589 )
590 }
591 }
592 }
593 }
594}