mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-09 08:01:20 +02:00
[FEAT] Add voicesearch button to search page (#637)
## Description
This PR is a rewrite of a previously submitted PR for this same feature.
It implements native Voice Search for Android TV using Android's
SpeechRecognizer API.
Key Changes
New Files:
- VoiceInputManager.kt - Handles SpeechRecognizer lifecycle, permission
management, and audio level normalization
- VoiceSearchButton.kt - Composable button with audio-reactive pulse and
full-screen listening dialog
UI/UX:
- Audio-reactive microphone button that pulses based on input volume
- Full-screen listening overlay with animated states:
- Listening - Pulsing bubble with ripple rings, shows partial
transcription
- Processing - Animated dots while waiting for final result
- Error - Red bubble with localized error message, auto-dismisses after
3s
### Related issues
Resolves https://github.com/damontecres/Wholphin/issues/515
### AI/LLM usage
The initial draft of this change from the prior PR was created with the
assistance of Claude Code, which was heavily leaned on for the overlay.
I have taken the initial draft, gone through it, and re-implemented by
hand to clean it up, narrow the scope (AI loves to touch and change
things unrelated to what you're working on) and make it more readable.
AI was used in the last stage to assist with finding any logic
errors/bugs I may have missed (by looking through code and also by
looking over ADB logs from testing on Nvidia Shield).. AI was also used
to create the tests found in TestVoiceInputManager.kt. Lastly, I fed the
diffs to an AI to help summarize the changes for this PR in an easy to
read manner.
---------
Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
parent
c4e64c3367
commit
d725821011
10 changed files with 1921 additions and 52 deletions
|
|
@ -0,0 +1,442 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.github.damontecres.wholphin.R
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.android.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import timber.log.Timber
|
||||
import java.io.Closeable
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val RMS_DB_MIN = -2.0f
|
||||
private const val RMS_DB_MAX = 10.0f
|
||||
private const val MAX_RESULTS = 1
|
||||
private const val LISTENING_TIMEOUT_MS = 8000L
|
||||
private const val RECOGNIZER_RECREATE_DELAY_MS = 150L
|
||||
|
||||
private val ERROR_TO_RESOURCE_MAP =
|
||||
mapOf(
|
||||
SpeechRecognizer.ERROR_AUDIO to R.string.voice_error_audio,
|
||||
SpeechRecognizer.ERROR_CLIENT to R.string.voice_error_client,
|
||||
SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS to R.string.voice_error_permissions,
|
||||
SpeechRecognizer.ERROR_NETWORK to R.string.voice_error_network,
|
||||
SpeechRecognizer.ERROR_NETWORK_TIMEOUT to R.string.voice_error_network_timeout,
|
||||
SpeechRecognizer.ERROR_NO_MATCH to R.string.voice_error_no_match,
|
||||
SpeechRecognizer.ERROR_RECOGNIZER_BUSY to R.string.voice_error_busy,
|
||||
SpeechRecognizer.ERROR_SERVER to R.string.voice_error_server,
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT to R.string.voice_error_speech_timeout,
|
||||
SpeechRecognizer.ERROR_TOO_MANY_REQUESTS to R.string.voice_error_busy,
|
||||
)
|
||||
|
||||
private val RETRYABLE_ERRORS =
|
||||
setOf(
|
||||
SpeechRecognizer.ERROR_NETWORK,
|
||||
SpeechRecognizer.ERROR_NETWORK_TIMEOUT,
|
||||
SpeechRecognizer.ERROR_SERVER,
|
||||
SpeechRecognizer.ERROR_NO_MATCH,
|
||||
SpeechRecognizer.ERROR_SPEECH_TIMEOUT,
|
||||
)
|
||||
|
||||
private fun normalizeRmsDb(rmsdB: Float) = ((rmsdB - RMS_DB_MIN) / (RMS_DB_MAX - RMS_DB_MIN)).coerceIn(0f, 1f)
|
||||
|
||||
sealed interface VoiceInputState {
|
||||
data object Idle : VoiceInputState
|
||||
|
||||
/** Recognizer is being initialized, not yet ready for speech. */
|
||||
data object Starting : VoiceInputState
|
||||
|
||||
data object Listening : VoiceInputState
|
||||
|
||||
data object Processing : VoiceInputState
|
||||
|
||||
data class Result(
|
||||
val text: String,
|
||||
) : VoiceInputState
|
||||
|
||||
data class Error(
|
||||
val messageResId: Int,
|
||||
val isRetryable: Boolean,
|
||||
) : VoiceInputState
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class VoiceInputManager
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : Closeable {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val mainDispatcher = provideMainDispatcher()
|
||||
private val scope = CoroutineScope(mainDispatcher + SupervisorJob())
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
private val connectivityManager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
private val audioFocusListener =
|
||||
AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
Timber.d("Permanent audio focus loss. Stopping listening.")
|
||||
stopListening()
|
||||
}
|
||||
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
Timber.d("Transient audio focus loss. Ignoring to allow SpeechRecognizer to work.")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Timber.d("Audio focus change: $focusChange")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val audioFocusRequest: AudioFocusRequest? by lazy {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
AudioFocusRequest
|
||||
.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||
.setOnAudioFocusChangeListener(audioFocusListener, handler)
|
||||
.build()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun requestAudioFocusCompat(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { audioManager.requestAudioFocus(it) }
|
||||
?: AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
} else {
|
||||
audioManager.requestAudioFocus(
|
||||
audioFocusListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun abandonAudioFocusCompat() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
|
||||
} else {
|
||||
audioManager.abandonAudioFocus(audioFocusListener)
|
||||
}
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<VoiceInputState>(VoiceInputState.Idle)
|
||||
val state: StateFlow<VoiceInputState> = _state.asStateFlow()
|
||||
|
||||
private val _soundLevel = MutableStateFlow(0f)
|
||||
val soundLevel: StateFlow<Float> = _soundLevel.asStateFlow()
|
||||
|
||||
private val _partialResult = MutableStateFlow("")
|
||||
val partialResult: StateFlow<String> = _partialResult.asStateFlow()
|
||||
|
||||
val isAvailable = SpeechRecognizer.isRecognitionAvailable(context)
|
||||
val hasPermission: Boolean
|
||||
get() =
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private var recognizer: SpeechRecognizer? = null
|
||||
private var timeoutJob: Job? = null
|
||||
|
||||
private fun provideMainDispatcher(): CoroutineDispatcher =
|
||||
try {
|
||||
Dispatchers.Main.immediate
|
||||
} catch (_: IllegalStateException) {
|
||||
// Fallback for unit tests where Main dispatcher is not installed
|
||||
handler.asCoroutineDispatcher()
|
||||
}
|
||||
|
||||
private val recognitionIntent by lazy {
|
||||
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
|
||||
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, MAX_RESULTS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isNetworkAvailable(): Boolean {
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
fun startListening() {
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
val currentState = _state.value
|
||||
if (currentState is VoiceInputState.Starting || currentState is VoiceInputState.Listening) {
|
||||
return@withLock
|
||||
}
|
||||
|
||||
val hadRecognizer = recognizer != null
|
||||
if (hadRecognizer) {
|
||||
destroyRecognizer()
|
||||
}
|
||||
|
||||
if (!isNetworkAvailable()) {
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_network,
|
||||
isRetryable = true,
|
||||
)
|
||||
}
|
||||
return@withLock
|
||||
}
|
||||
|
||||
val focusResult = requestAudioFocusCompat()
|
||||
if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_audio,
|
||||
isRetryable = true,
|
||||
)
|
||||
}
|
||||
return@withLock
|
||||
}
|
||||
|
||||
cancelTimeout()
|
||||
handler.post {
|
||||
_partialResult.value = ""
|
||||
_soundLevel.value = 0f
|
||||
_state.value = VoiceInputState.Starting
|
||||
}
|
||||
|
||||
// Give the OS time to release the mic before recreating when replacing an old recognizer
|
||||
if (hadRecognizer) {
|
||||
delay(RECOGNIZER_RECREATE_DELAY_MS)
|
||||
}
|
||||
|
||||
val newRecognizer = SpeechRecognizer.createSpeechRecognizer(context)
|
||||
recognizer = newRecognizer
|
||||
newRecognizer.setRecognitionListener(createRecognitionListener(newRecognizer))
|
||||
|
||||
try {
|
||||
newRecognizer.startListening(recognitionIntent)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to start speech recognition")
|
||||
destroyRecognizer()
|
||||
cancelTimeout()
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_start_failed,
|
||||
isRetryable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopListening() {
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
cancelTimeout()
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelTimeout() {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
}
|
||||
|
||||
private fun startTimeout() {
|
||||
cancelTimeout()
|
||||
timeoutJob =
|
||||
scope.launch {
|
||||
delay(LISTENING_TIMEOUT_MS)
|
||||
mutex.withLock {
|
||||
if (_state.value is VoiceInputState.Listening && recognizer != null) {
|
||||
val partial = _partialResult.value
|
||||
destroyRecognizer()
|
||||
handler.post {
|
||||
_soundLevel.value = 0f
|
||||
_partialResult.value = ""
|
||||
_state.value =
|
||||
if (partial.isNotBlank()) {
|
||||
VoiceInputState.Result(partial)
|
||||
} else {
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_timeout,
|
||||
isRetryable = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun acknowledge() {
|
||||
handler.post { _state.value = VoiceInputState.Idle }
|
||||
}
|
||||
|
||||
fun onPermissionGranted() = startListening()
|
||||
|
||||
fun onPermissionDenied() {
|
||||
Timber.w("RECORD_AUDIO permission denied")
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_permissions,
|
||||
isRetryable = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun destroyRecognizer() {
|
||||
abandonAudioFocusCompat()
|
||||
// Null out FIRST to invalidate callbacks before cancel() can trigger them
|
||||
val rec = recognizer
|
||||
recognizer = null
|
||||
rec?.let {
|
||||
try {
|
||||
it.cancel()
|
||||
it.destroy()
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e, "Error destroying speech recognizer")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
destroyRecognizer()
|
||||
handler.post {
|
||||
_soundLevel.value = 0f
|
||||
_partialResult.value = ""
|
||||
_state.value = VoiceInputState.Idle
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRecognitionListener(activeRecognizer: SpeechRecognizer) =
|
||||
object : RecognitionListener {
|
||||
// Guard against callbacks from zombie recognizers
|
||||
private fun isValid() = recognizer === activeRecognizer
|
||||
|
||||
override fun onReadyForSpeech(params: Bundle?) {
|
||||
if (!isValid()) return
|
||||
handler.post { _state.value = VoiceInputState.Listening }
|
||||
startTimeout()
|
||||
}
|
||||
|
||||
override fun onBeginningOfSpeech() {
|
||||
if (!isValid()) return
|
||||
}
|
||||
|
||||
override fun onRmsChanged(rmsdB: Float) {
|
||||
if (!isValid()) return
|
||||
handler.post { _soundLevel.value = normalizeRmsDb(rmsdB) }
|
||||
}
|
||||
|
||||
override fun onBufferReceived(buffer: ByteArray?) = Unit
|
||||
|
||||
override fun onEndOfSpeech() {
|
||||
if (!isValid()) return
|
||||
cancelTimeout()
|
||||
handler.post { _state.value = VoiceInputState.Processing }
|
||||
}
|
||||
|
||||
override fun onError(error: Int) {
|
||||
if (!isValid()) return
|
||||
Timber.e("Voice recognition error code: $error")
|
||||
cancelTimeout()
|
||||
destroyRecognizer()
|
||||
|
||||
if (error == SpeechRecognizer.ERROR_TOO_MANY_REQUESTS) {
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown,
|
||||
isRetryable = false,
|
||||
)
|
||||
_soundLevel.value = 0f
|
||||
_partialResult.value = ""
|
||||
}
|
||||
return
|
||||
}
|
||||
handler.post {
|
||||
_state.value =
|
||||
VoiceInputState.Error(
|
||||
messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown,
|
||||
isRetryable = error in RETRYABLE_ERRORS,
|
||||
)
|
||||
_soundLevel.value = 0f
|
||||
_partialResult.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResults(results: Bundle?) {
|
||||
if (!isValid()) return
|
||||
cancelTimeout()
|
||||
val spokenText =
|
||||
results
|
||||
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
?.firstOrNull()
|
||||
handler.post {
|
||||
_state.value =
|
||||
if (!spokenText.isNullOrBlank()) {
|
||||
VoiceInputState.Result(spokenText)
|
||||
} else {
|
||||
VoiceInputState.Error(
|
||||
messageResId = R.string.voice_error_no_match,
|
||||
isRetryable = true,
|
||||
)
|
||||
}
|
||||
_soundLevel.value = 0f
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPartialResults(partialResults: Bundle?) {
|
||||
if (!isValid()) return
|
||||
partialResults
|
||||
?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
|
||||
?.firstOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { handler.post { _partialResult.value = it } }
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
eventType: Int,
|
||||
params: Bundle?,
|
||||
) = Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
package com.github.damontecres.wholphin.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSizeIn
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.tv.material3.ButtonDefaults
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.OutlinedButton
|
||||
import androidx.tv.material3.OutlinedButtonDefaults
|
||||
import androidx.tv.material3.Text
|
||||
import androidx.tv.material3.surfaceColorAtElevation
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private const val ERROR_AUTO_DISMISS_DELAY_MS = 3000L
|
||||
private const val SOUND_LEVEL_SCALE_FACTOR = 0.15f
|
||||
private val BUBBLE_SIZE = 160.dp
|
||||
private val MIC_ICON_FONT_SIZE = 56.sp
|
||||
private val BUTTON_ICON_FONT_SIZE = 20.sp
|
||||
private val CONTENT_SPACING = 48.dp
|
||||
private val HORIZONTAL_PADDING = 64.dp
|
||||
private val DISMISS_HINT_BOTTOM_PADDING = 32.dp
|
||||
private const val HINT_TEXT_ALPHA = 0.5f
|
||||
private const val SOUND_LEVEL_ANIM_MS = 100
|
||||
private const val BASE_PULSE_ANIM_MS = 800
|
||||
private const val RIPPLE_ANIM_MS = 1500
|
||||
private const val DOTS_ANIM_MS = 1200
|
||||
private const val RIPPLE_CANVAS_SCALE = 1.8f
|
||||
private const val MAX_RIPPLE_EXPANSION = 0.35f
|
||||
private val RIPPLE_STROKE_WIDTH = 2.dp
|
||||
private const val RIPPLE_MAX_ALPHA = 0.4f
|
||||
|
||||
private fun VoiceInputState.shouldShowOverlay() =
|
||||
this is VoiceInputState.Starting ||
|
||||
this is VoiceInputState.Listening ||
|
||||
this is VoiceInputState.Processing ||
|
||||
this is VoiceInputState.Error
|
||||
|
||||
@Composable
|
||||
fun VoiceSearchButton(
|
||||
onSpeechResult: (String) -> Unit,
|
||||
voiceInputManager: VoiceInputManager?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (voiceInputManager == null || !voiceInputManager.isAvailable) return
|
||||
|
||||
val state by voiceInputManager.state.collectAsState()
|
||||
val soundLevel by voiceInputManager.soundLevel.collectAsState()
|
||||
val partialResult by voiceInputManager.partialResult.collectAsState()
|
||||
|
||||
LaunchedEffect(state) {
|
||||
val currentState = state
|
||||
when (currentState) {
|
||||
is VoiceInputState.Result -> {
|
||||
onSpeechResult(currentState.text)
|
||||
// Small delay to allow focus to be restored before dialog dismisses
|
||||
delay(50)
|
||||
voiceInputManager.acknowledge()
|
||||
}
|
||||
|
||||
is VoiceInputState.Error -> {
|
||||
if (!currentState.isRetryable) {
|
||||
delay(ERROR_AUTO_DISMISS_DELAY_MS)
|
||||
voiceInputManager.acknowledge()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
voiceInputManager.onPermissionGranted()
|
||||
} else {
|
||||
voiceInputManager.onPermissionDenied()
|
||||
}
|
||||
}
|
||||
|
||||
if (state.shouldShowOverlay()) {
|
||||
val errorState = state as? VoiceInputState.Error
|
||||
val errorMessage = errorState?.messageResId?.let { stringResource(it) }
|
||||
VoiceSearchOverlay(
|
||||
soundLevel = soundLevel,
|
||||
partialResult = partialResult,
|
||||
isStarting = state is VoiceInputState.Starting,
|
||||
isProcessing = state is VoiceInputState.Processing,
|
||||
errorMessage = errorMessage,
|
||||
isRetryable = errorState?.isRetryable == true,
|
||||
onRetry = { voiceInputManager.startListening() },
|
||||
onDismiss = { voiceInputManager.stopListening() },
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
when (state) {
|
||||
is VoiceInputState.Starting,
|
||||
is VoiceInputState.Listening,
|
||||
-> {
|
||||
voiceInputManager.stopListening()
|
||||
}
|
||||
|
||||
else -> {
|
||||
if (voiceInputManager.hasPermission) {
|
||||
voiceInputManager.startListening()
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
modifier.requiredSizeIn(
|
||||
minWidth = MinButtonSize,
|
||||
minHeight = MinButtonSize,
|
||||
maxWidth = MinButtonSize,
|
||||
maxHeight = MinButtonSize,
|
||||
),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val voiceSearchDesc = stringResource(R.string.voice_search)
|
||||
Text(
|
||||
text = stringResource(R.string.fa_microphone),
|
||||
fontFamily = FontAwesome,
|
||||
fontSize = BUTTON_ICON_FONT_SIZE,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.semantics { contentDescription = voiceSearchDesc },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceRippleRings(
|
||||
rippleProgress: Float,
|
||||
bubbleSize: androidx.compose.ui.unit.Dp,
|
||||
color: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val rippleStroke =
|
||||
remember(density) {
|
||||
Stroke(width = with(density) { RIPPLE_STROKE_WIDTH.toPx() })
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier.size(bubbleSize * RIPPLE_CANVAS_SCALE)) {
|
||||
val canvasCenter = center
|
||||
val baseRadius = bubbleSize.toPx() / 2
|
||||
val maxExpansion = bubbleSize.toPx() * MAX_RIPPLE_EXPANSION
|
||||
|
||||
for (i in 0..2) {
|
||||
val ringProgress = (rippleProgress + (i * 0.33f)) % 1f
|
||||
val ringRadius = baseRadius + (ringProgress * maxExpansion)
|
||||
val ringAlpha = (1f - ringProgress) * RIPPLE_MAX_ALPHA
|
||||
drawCircle(
|
||||
color = color.copy(alpha = ringAlpha),
|
||||
radius = ringRadius,
|
||||
center = canvasCenter,
|
||||
style = rippleStroke,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStatusText(
|
||||
errorMessage: String?,
|
||||
partialResult: String,
|
||||
isStarting: Boolean,
|
||||
isProcessing: Boolean,
|
||||
startingText: String,
|
||||
processingText: String,
|
||||
listeningText: String,
|
||||
dotCount: Int,
|
||||
): Pair<String, String> {
|
||||
val dots = ".".repeat(dotCount)
|
||||
return when {
|
||||
errorMessage != null -> errorMessage to errorMessage
|
||||
isProcessing -> (processingText + dots) to processingText
|
||||
isStarting -> (startingText + dots) to startingText
|
||||
partialResult.isNotBlank() -> partialResult to partialResult
|
||||
else -> (listeningText + dots) to listeningText
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceSearchOverlay(
|
||||
soundLevel: Float,
|
||||
partialResult: String,
|
||||
isStarting: Boolean,
|
||||
isProcessing: Boolean,
|
||||
errorMessage: String?,
|
||||
isRetryable: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
val onPrimaryColor = MaterialTheme.colorScheme.onPrimary
|
||||
val errorColor = MaterialTheme.colorScheme.error
|
||||
|
||||
val animatedSoundLevel by animateFloatAsState(
|
||||
targetValue = soundLevel,
|
||||
animationSpec = tween(durationMillis = SOUND_LEVEL_ANIM_MS),
|
||||
label = "soundLevel",
|
||||
)
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val basePulse by infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 1.05f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = BASE_PULSE_ANIM_MS),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "basePulse",
|
||||
)
|
||||
|
||||
// Only animate ripples when actively listening (not starting, processing, or in error)
|
||||
val shouldAnimateRipples = !isStarting && !isProcessing && errorMessage == null
|
||||
val rippleProgress by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = if (shouldAnimateRipples) 1f else 0f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = RIPPLE_ANIM_MS),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "ripple",
|
||||
)
|
||||
|
||||
val dotAnimation by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 4f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(durationMillis = DOTS_ANIM_MS),
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "dots",
|
||||
)
|
||||
|
||||
val bubbleScale = basePulse + (animatedSoundLevel * SOUND_LEVEL_SCALE_FACTOR)
|
||||
|
||||
val statusFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) {
|
||||
statusFocusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties =
|
||||
DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true,
|
||||
usePlatformDefaultWidth = false,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth(0.85f)
|
||||
.wrapContentHeight()
|
||||
.padding(vertical = 48.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(CONTENT_SPACING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = HORIZONTAL_PADDING),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val rippleAlpha = if (shouldAnimateRipples) 1f else 0f
|
||||
Box(modifier = Modifier.graphicsLayer { alpha = rippleAlpha }) {
|
||||
VoiceRippleRings(
|
||||
rippleProgress = rippleProgress,
|
||||
bubbleSize = BUBBLE_SIZE,
|
||||
color = primaryColor,
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(BUBBLE_SIZE)
|
||||
.graphicsLayer {
|
||||
scaleX = bubbleScale
|
||||
scaleY = bubbleScale
|
||||
}.clip(CircleShape)
|
||||
.background(if (errorMessage != null) errorColor else primaryColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val voiceSearchDesc = stringResource(R.string.voice_search)
|
||||
Text(
|
||||
text = stringResource(R.string.fa_microphone),
|
||||
fontFamily = FontAwesome,
|
||||
fontSize = MIC_ICON_FONT_SIZE,
|
||||
color = onPrimaryColor,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.semantics { contentDescription = voiceSearchDesc },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val startingText = stringResource(R.string.voice_starting)
|
||||
val processingText = stringResource(R.string.processing)
|
||||
val listeningText = stringResource(R.string.voice_search_prompt)
|
||||
val (statusText, accessibilityDescription) =
|
||||
getStatusText(
|
||||
errorMessage = errorMessage,
|
||||
partialResult = partialResult,
|
||||
isStarting = isStarting,
|
||||
isProcessing = isProcessing,
|
||||
startingText = startingText,
|
||||
processingText = processingText,
|
||||
listeningText = listeningText,
|
||||
dotCount = dotAnimation.toInt(),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).focusRequester(statusFocusRequester),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = if (errorMessage != null) errorColor else Color.White,
|
||||
modifier = Modifier.semantics { contentDescription = accessibilityDescription },
|
||||
)
|
||||
|
||||
if (errorMessage != null && isRetryable) {
|
||||
OutlinedButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.padding(top = 16.dp),
|
||||
shape = ButtonDefaults.shape(RoundedCornerShape(50)),
|
||||
colors =
|
||||
OutlinedButtonDefaults.colors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.retry),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.press_back_to_cancel),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = HINT_TEXT_ALPHA),
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = DISMISS_HINT_BOTTOM_PADDING),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,11 @@ package com.github.damontecres.wholphin.ui.main
|
|||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.focusGroup
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
|
|
@ -25,7 +24,14 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
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.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
|
|
@ -34,6 +40,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
|
|
@ -51,11 +58,13 @@ import com.github.damontecres.wholphin.ui.cards.EpisodeCard
|
|||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.SearchEditTextBox
|
||||
import com.github.damontecres.wholphin.ui.components.VoiceInputManager
|
||||
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.onMain
|
||||
import com.github.damontecres.wholphin.ui.rememberPosition
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
|
@ -81,7 +90,12 @@ class SearchViewModel
|
|||
val api: ApiClient,
|
||||
val navigationManager: NavigationManager,
|
||||
private val seerrService: SeerrService,
|
||||
val voiceInputManager: VoiceInputManager,
|
||||
) : ViewModel() {
|
||||
val voiceState = voiceInputManager.state
|
||||
val soundLevel = voiceInputManager.soundLevel
|
||||
val partialResult = voiceInputManager.partialResult
|
||||
|
||||
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||
|
|
@ -158,6 +172,10 @@ class SearchViewModel
|
|||
}
|
||||
}
|
||||
|
||||
init {
|
||||
addCloseable(voiceInputManager)
|
||||
}
|
||||
|
||||
fun getHints(query: String) {
|
||||
// TODO
|
||||
// api.searchApi.getSearchHints()
|
||||
|
|
@ -182,12 +200,16 @@ sealed interface SearchResult {
|
|||
) : SearchResult
|
||||
}
|
||||
|
||||
private const val MOVIE_ROW = 0
|
||||
private const val SEARCH_ROW = 0
|
||||
private const val MOVIE_ROW = SEARCH_ROW + 1
|
||||
private const val COLLECTION_ROW = MOVIE_ROW + 1
|
||||
private const val SERIES_ROW = COLLECTION_ROW + 1
|
||||
private const val EPISODE_ROW = SERIES_ROW + 1
|
||||
private const val SEERR_ROW = EPISODE_ROW + 1
|
||||
|
||||
/** Delay for focus to settle after voice search dialog dismisses. */
|
||||
private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L
|
||||
|
||||
@Composable
|
||||
fun SearchPage(
|
||||
userPreferences: UserPreferences,
|
||||
|
|
@ -205,26 +227,59 @@ fun SearchPage(
|
|||
|
||||
// val query = rememberTextFieldState()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } }
|
||||
|
||||
var position by rememberPosition()
|
||||
var position by rememberPosition(0, 0)
|
||||
var searchClicked by rememberSaveable { mutableStateOf(false) }
|
||||
var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
LifecycleResumeEffect(Unit) {
|
||||
onPauseOrDispose {
|
||||
viewModel.voiceInputManager.stopListening()
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerImmediateSearch(searchQuery: String) {
|
||||
immediateSearchQuery = searchQuery
|
||||
searchClicked = true
|
||||
viewModel.search(searchQuery)
|
||||
}
|
||||
|
||||
LaunchedEffect(query) {
|
||||
delay(750L)
|
||||
viewModel.search(query)
|
||||
when {
|
||||
immediateSearchQuery == query -> {
|
||||
immediateSearchQuery = null
|
||||
}
|
||||
|
||||
else -> {
|
||||
delay(750L)
|
||||
viewModel.search(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.tryRequestFocus()
|
||||
focusRequesters.getOrNull(position.row)?.tryRequestFocus()
|
||||
}
|
||||
val onClickItem = { index: Int, item: BaseItem ->
|
||||
viewModel.navigationManager.navigateTo(item.destination())
|
||||
}
|
||||
LaunchedEffect(searchClicked, movies, collections, series, episodes) {
|
||||
if (searchClicked) {
|
||||
if (listOf(movies, collections, series, episodes).any { it is SearchResult.Success }) {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
searchClicked = false
|
||||
|
||||
LaunchedEffect(searchClicked, movies, collections, series, episodes, seerrResults) {
|
||||
if (!searchClicked) return@LaunchedEffect
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
// Want to focus on the first successful row after all of the ones before it are finished searching
|
||||
val results = listOf(movies, collections, series, episodes, seerrResults)
|
||||
val firstSuccess =
|
||||
results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr }
|
||||
if (firstSuccess >= 0) {
|
||||
val anyBeforeSearching =
|
||||
results.subList(0, firstSuccess).any { it is SearchResult.Searching }
|
||||
if (!anyBeforeSearching) {
|
||||
// 0-th row is the search bar
|
||||
position = RowColumn(firstSuccess + 1, 0)
|
||||
onMain { focusRequesters[firstSuccess + 1].tryRequestFocus() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -239,27 +294,67 @@ fun SearchPage(
|
|||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
BackHandler(focused) {
|
||||
keyboardController?.hide()
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
var isTextFieldFocused by remember { mutableStateOf(false) }
|
||||
val textFieldFocusRequester = remember { FocusRequester() }
|
||||
|
||||
BackHandler(isTextFieldFocused) {
|
||||
when {
|
||||
isSearchActive -> {
|
||||
isSearchActive = false
|
||||
keyboardController?.hide()
|
||||
}
|
||||
|
||||
else -> {
|
||||
focusManager.moveFocus(FocusDirection.Next)
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchEditTextBox(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
onSearchClick = {
|
||||
viewModel.search(query)
|
||||
searchClicked = true
|
||||
},
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
Modifier
|
||||
.ifElse(
|
||||
position.row < MOVIE_ROW,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
interactionSource = interactionSource,
|
||||
)
|
||||
.focusGroup()
|
||||
.focusRestorer(textFieldFocusRequester)
|
||||
.focusRequester(focusRequesters[SEARCH_ROW]),
|
||||
) {
|
||||
VoiceSearchButton(
|
||||
onSpeechResult = { spokenText ->
|
||||
query = spokenText
|
||||
triggerImmediateSearch(spokenText)
|
||||
},
|
||||
voiceInputManager = viewModel.voiceInputManager,
|
||||
)
|
||||
|
||||
SearchEditTextBox(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
isSearchActive = true
|
||||
query = it
|
||||
},
|
||||
onSearchClick = { triggerImmediateSearch(query) },
|
||||
readOnly = !isSearchActive,
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(textFieldFocusRequester)
|
||||
.onFocusChanged { state ->
|
||||
isTextFieldFocused = state.isFocused
|
||||
if (!state.isFocused) isSearchActive = false
|
||||
}.onPreviewKeyEvent { event ->
|
||||
val isActivationKey =
|
||||
event.key in listOf(Key.DirectionCenter, Key.Enter)
|
||||
if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) {
|
||||
isSearchActive = true
|
||||
keyboardController?.show()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
searchResultRow(
|
||||
|
|
@ -267,7 +362,7 @@ fun SearchPage(
|
|||
result = movies,
|
||||
rowIndex = MOVIE_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequester,
|
||||
focusRequester = focusRequesters[MOVIE_ROW],
|
||||
onClickItem = onClickItem,
|
||||
onClickPosition = { position = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -277,7 +372,7 @@ fun SearchPage(
|
|||
result = collections,
|
||||
rowIndex = COLLECTION_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequester,
|
||||
focusRequester = focusRequesters[COLLECTION_ROW],
|
||||
onClickItem = onClickItem,
|
||||
onClickPosition = { position = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -287,7 +382,7 @@ fun SearchPage(
|
|||
result = series,
|
||||
rowIndex = SERIES_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequester,
|
||||
focusRequester = focusRequesters[SERIES_ROW],
|
||||
onClickItem = onClickItem,
|
||||
onClickPosition = { position = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -297,7 +392,7 @@ fun SearchPage(
|
|||
result = episodes,
|
||||
rowIndex = EPISODE_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequester,
|
||||
focusRequester = focusRequesters[EPISODE_ROW],
|
||||
onClickItem = onClickItem,
|
||||
onClickPosition = { position = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -310,13 +405,7 @@ fun SearchPage(
|
|||
},
|
||||
onLongClick = onLongClick,
|
||||
imageHeight = 140.dp,
|
||||
modifier =
|
||||
mod
|
||||
.padding(horizontal = 8.dp)
|
||||
.ifElse(
|
||||
position.row == EPISODE_ROW && position.column == index,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
modifier = mod.padding(horizontal = 8.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -325,7 +414,7 @@ fun SearchPage(
|
|||
result = seerrResults,
|
||||
rowIndex = SEERR_ROW,
|
||||
position = position,
|
||||
focusRequester = focusRequester,
|
||||
focusRequester = focusRequesters[SEERR_ROW],
|
||||
onClickItem = { _, _ ->
|
||||
// no-op
|
||||
},
|
||||
|
|
@ -372,12 +461,7 @@ fun LazyListScope.searchResultRow(
|
|||
},
|
||||
onLongClick = onLongClick,
|
||||
imageHeight = Cards.height2x3,
|
||||
modifier =
|
||||
mod
|
||||
.ifElse(
|
||||
position.row == rowIndex && position.column == index,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
modifier = mod,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
|
@ -417,7 +501,7 @@ fun LazyListScope.searchResultRow(
|
|||
items = r.items,
|
||||
onClickItem = onClickItem,
|
||||
onLongClickItem = { _, _ -> },
|
||||
modifier = modifier,
|
||||
modifier = modifier.focusRequester(focusRequester),
|
||||
cardContent = cardContent,
|
||||
)
|
||||
}
|
||||
|
|
@ -439,7 +523,7 @@ fun LazyListScope.searchResultRow(
|
|||
onClickDiscover?.invoke(index, item)
|
||||
},
|
||||
onLongClickItem = { _, _ -> },
|
||||
modifier = modifier,
|
||||
modifier = modifier.focusRequester(focusRequester),
|
||||
cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit ->
|
||||
DiscoverItemCard(
|
||||
item = item,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue