package dev.stave.ui.components
import android.content.Context
import android.content.Intent
import android.speech.RecognizerIntent
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.ui.theme.AccentAmber
import dev.stave.ui.theme.OnAmber
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.SurfaceRaised
import dev.stave.ui.theme.withInterDigits
/**
* Voice search dialog — system-intent path.
*
* Earlier iterations used the in-process [android.speech.SpeechRecognizer]
* with our own listening UI. That path required RECORD_AUDIO permission,
* required an on-device or third-party recognizer service to be installed,
* and was brittle on TV — particularly Shield, where the in-process binding
* would either fail outright or hang silently.
*
* This implementation replaces all of that with the system
* [RecognizerIntent.ACTION_RECOGNIZE_SPEECH] activity intent — exactly the
* code path the system keyboard's mic button uses. The system shows its own
* listening UI; capture happens in the system's process, not ours; we get
* back a transcribed string via the activity result. RECORD_AUDIO is no
* longer required for this path.
*
* UI lifecycle:
* 1. On first composition, fire the intent via
* [rememberLauncherForActivityResult]. While the system UI is presenting,
* we render a thin scrim (the user mostly only sees this in the rare
* case where the system UI takes time to draw).
* 2. On intent return: extract [RecognizerIntent.EXTRA_RESULTS], take the
* first non-blank entry, call [onResult] with it. Empty / cancelled →
* [onDismiss].
* 3. If no recognizer is installed, the launcher returns immediately with
* a generic failure; we render the [UnavailableCard] so the user knows
* to install a recognizer (typically the "Google" app on Android TV)
* rather than wondering why the dialog flickered.
*
* Privacy / offline guarantees: this path uses whatever recognizer the system
* picks. On modern Android TV with Google's recognizer that may be on-device;
* on older devices it may go to network. The previous "guaranteed offline"
* promise from the in-process path no longer holds — but that path didn't
* actually deliver in practice (no on-device model + no working third-party
* service = nothing recognized). The trade-off is reliability over privacy
* here, matching what the user actually experiences elsewhere on the device.
*/
@Composable
fun VoiceSearchDialog(
onResult: (String) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
var failed by remember { mutableStateOf(false) }
var launched by remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { result: ActivityResult ->
// Result handling. EXTRA_RESULTS holds an ArrayList<String> of candidate
// transcriptions ordered by confidence. We take the first non-blank one.
val data: Intent? = result.data
val matches = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
val top = matches?.firstOrNull { it.isNotBlank() }?.trim()
if (!top.isNullOrBlank()) {
onResult(top)
} else {
// Cancelled, no match, or recognizer dialog dismissed without speech.
// Treat all as "user gave up" — close the dialog cleanly.
onDismiss()
}
}
// Fire the intent exactly once. LaunchedEffect with Unit so a recomposition
// doesn't re-launch (which would stack a second system dialog on top of the
// first on devices that handle the intent slowly). If the intent can't be
// resolved at all, surface the unavailable state so the user gets a real
// explanation instead of the dialog silently doing nothing.
LaunchedEffect(Unit) {
if (launched) return@LaunchedEffect
launched = true
val intent = buildRecognizerIntent(context)
val canResolve = intent.resolveActivity(context.packageManager) != null
if (!canResolve) {
failed = true
return@LaunchedEffect
}
runCatching { launcher.launch(intent) }
.onFailure { failed = true }
}
BackHandler(onBack = onDismiss)
if (failed) {
UnavailableCard(onDismiss = onDismiss)
} else {
// Thin scrim only — the system's own dialog is layered on top so we
// shouldn't put any chrome here that competes with it. Black at low
// alpha so the underlying screen dims slightly during the brief
// window before the system UI takes over.
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { /* swallow */ },
),
)
}
}
/**
* Build the recognizer intent used by [VoiceSearchDialog] and any future
* caller that wants the same configuration.
*
* - LANGUAGE_MODEL_FREE_FORM — full sentences, not short commands.
* - EXTRA_PROMPT — what the system shows above its listening UI.
* - EXTRA_CALLING_PACKAGE — some recognizer implementations gate on this.
* - EXTRA_PREFER_OFFLINE — hint only; the system decides.
*
* EXTRA_MAX_RESULTS is left at the default (1). We always take the top result.
*/
private fun buildRecognizerIntent(context: Context): Intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Search your library")
putExtra(RecognizerIntent.EXTRA_CALLING_PACKAGE, context.packageName)
putExtra(RecognizerIntent.EXTRA_PREFER_OFFLINE, true)
}
/**
* Returns true iff the system can resolve [RecognizerIntent.ACTION_RECOGNIZE_SPEECH].
*
* Used by the keyboard owner (BrowseScreen) to decide whether to render the
* mic key at all. Cheap — a single PackageManager query — and accurate for
* this path: if resolveActivity returns null, the launcher.launch above will
* fail too. Compares favorably to the old SpeechRecognizer.isRecognitionAvailable
* call which often disagreed with reality on TV builds.
*/
fun voiceSearchAvailable(context: Context): Boolean =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).resolveActivity(context.packageManager) != null
@Composable
private fun UnavailableCard(onDismiss: () -> Unit) {
BackHandler(onBack = onDismiss)
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.75f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.fillMaxWidth(0.92f)
.widthIn(max = 600.dp)
.clip(RoundedCornerShape(16.dp))
.background(SurfaceRaised)
.padding(horizontal = 32.dp, vertical = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Voice unavailable",
style = MaterialTheme.typography.titleLarge,
color = OnPrimary,
)
Spacer(Modifier.height(12.dp))
Text(
"No speech recognizer is installed on this device. " +
"Install Google or another voice-input app to use voice search.",
style = MaterialTheme.typography.bodyMedium,
color = OnMuted,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
DialogButton("Close", onDismiss, initialFocus = true)
}
}
}
}
@Composable
private fun DialogButton(
label: String,
onClick: () -> Unit,
initialFocus: Boolean,
) {
val focusRequester = remember { FocusRequester() }
val interaction = remember { MutableInteractionSource() }
val focused by interaction.collectIsFocusedAsState()
LaunchedEffect(initialFocus) {
if (initialFocus) runCatching { focusRequester.requestFocus() }
}
Box(
Modifier
.clip(RoundedCornerShape(4.dp))
.background(if (focused) AccentAmber else SurfaceRaised)
.focusRequester(focusRequester)
.onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown &&
(ev.key == Key.Enter ||
ev.key == Key.NumPadEnter ||
ev.key == Key.DirectionCenter)
) {
onClick()
true
} else false
}
.clickable(interaction, indication = null) { onClick() }
.padding(horizontal = 24.dp, vertical = 12.dp),
) {
Text(
label,
style = MaterialTheme.typography.labelLarge,
color = if (focused) OnAmber else OnPrimary,
)
}
}