Add option to hide the voice search button (#1423)

## Description

Adds a "Show voice search button" toggle to the search page's view
options dialog, alongside the existing "Combined search results" toggle.
When disabled, the voice search button is hidden from the search row,
taking it out of the D-pad focus path.

The new field `show_voice_search_button` is added to `SearchPreferences`
(default `true`). The serializer applies the default for new installs,
and a new entry in `AppUpgradeHandler` sets it to `true` for users
upgrading from earlier versions so existing behaviour is preserved.

### Related issues

Discussion: https://github.com/damontecres/Wholphin/discussions/1419

### Testing

Tested on an Android TV (1080p) AVD running API 34:

- Toggle on (default): voice search button visible at the top of the
search row.
- Toggle off: button disappears; only the search text field remains.
- Toggle back on: button reappears.
- Upgrade handler verified by spoofing `version.current.name` to an
older value in SharedPreferences and force-restarting the app; after
`needUpgrade()` fired, the toggle was on as expected.

The upgrade-handler entry currently uses `0.6.4-8-g0` (based on current
`main` HEAD); happy to bump this if you'd prefer a different target
version on merge.

## Screenshots

<img width="1920" height="1080" alt="pr1-01-home"
src="https://github.com/user-attachments/assets/0d5cdad0-5547-44f3-b4a5-5d15f71dddc0"
/>
<img width="1920" height="1080" alt="pr1-02-toggled-off"
src="https://github.com/user-attachments/assets/1e574229-0838-487d-b943-83d394a8193d"
/>

## AI or LLM usage

I used Claude to scaffold the proto/serializer/upgrade-handler/dialog
changes, mirroring the existing `combined_search_results` pattern. I
reviewed each diff before committing and tested the toggle manually on
the emulator, including the upgrade-handler simulation.

---------

Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
JustinZeus 2026-05-21 21:31:17 +02:00 committed by GitHub
parent 3880e562f6
commit fa914845f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 81 additions and 18 deletions

View file

@ -105,6 +105,7 @@ class AppPreferencesSerializer
.newBuilder() .newBuilder()
.apply { .apply {
combinedSearchResults = false combinedSearchResults = false
showVoiceSearchButton = true
}.build() }.build()
subtitlesPreferences = subtitlesPreferences =

View file

@ -21,6 +21,7 @@ import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
import com.github.damontecres.wholphin.preferences.updateSearchPreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
@ -334,5 +335,11 @@ class AppUpgradeHandler
it.updatePlaybackPreferences { cinemaMode = AppPreference.CinemaMode.defaultValue } it.updatePlaybackPreferences { cinemaMode = AppPreference.CinemaMode.defaultValue }
} }
} }
if (previous.isEqualOrBefore(Version.fromString("0.6.4-8-g0"))) {
appPreferences.updateData {
it.updateSearchPreferences { showVoiceSearchButton = true }
}
}
} }
} }

View file

@ -4,7 +4,11 @@ import android.view.Gravity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
@ -72,6 +76,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateSearchPreferences import com.github.damontecres.wholphin.preferences.updateSearchPreferences
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
@ -104,11 +109,7 @@ import com.github.damontecres.wholphin.util.SearchRelevance
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -127,17 +128,13 @@ class SearchViewModel
private val appPreferences: DataStore<AppPreferences>, private val appPreferences: DataStore<AppPreferences>,
private val seerrService: SeerrService, private val seerrService: SeerrService,
val voiceInputManager: VoiceInputManager, val voiceInputManager: VoiceInputManager,
val userPreferencesService: UserPreferencesService,
) : ViewModel() { ) : ViewModel() {
val voiceState = voiceInputManager.state val voiceState = voiceInputManager.state
val soundLevel = voiceInputManager.soundLevel val soundLevel = voiceInputManager.soundLevel
val partialResult = voiceInputManager.partialResult val partialResult = voiceInputManager.partialResult
val seerrActive = seerrService.active val seerrActive = seerrService.active
val combinedModeFlow: StateFlow<Boolean> =
appPreferences.data
.map { it.interfacePreferences.searchPreferences.combinedSearchResults }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery) val movies = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val series = MutableLiveData<SearchResult>(SearchResult.NoQuery) val series = MutableLiveData<SearchResult>(SearchResult.NoQuery)
val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery) val episodes = MutableLiveData<SearchResult>(SearchResult.NoQuery)
@ -275,6 +272,16 @@ class SearchViewModel
} }
} }
fun setVoiceSearchButtonVisible(visible: Boolean) {
viewModelScope.launchIO {
appPreferences.updateData {
it.updateSearchPreferences {
showVoiceSearchButton = visible
}
}
}
}
private fun searchSeerr(query: String) { private fun searchSeerr(query: String) {
viewModelScope.launchIO { viewModelScope.launchIO {
if (seerrService.active.first()) { if (seerrService.active.first()) {
@ -350,7 +357,14 @@ fun SearchPage(
val songs by viewModel.songs.observeAsState(SearchResult.NoQuery) val songs by viewModel.songs.observeAsState(SearchResult.NoQuery)
val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery) val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery)
val combinedResults by viewModel.combinedResults.observeAsState(SearchResult.NoQuery) val combinedResults by viewModel.combinedResults.observeAsState(SearchResult.NoQuery)
val combinedMode by viewModel.combinedModeFlow.collectAsState()
// Start with current preferences, but collect updates when view options change
val prefs =
viewModel.userPreferencesService.flow
.collectAsState(userPreferences)
.value.appPreferences.interfacePreferences.searchPreferences
val combinedMode = prefs.combinedSearchResults
val voiceSearchButtonVisible = prefs.showVoiceSearchButton
// val query = rememberTextFieldState() // val query = rememberTextFieldState()
var query by rememberSaveable { mutableStateOf("") } var query by rememberSaveable { mutableStateOf("") }
@ -499,7 +513,7 @@ fun SearchPage(
} }
Row( Row(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(0.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = modifier =
Modifier Modifier
@ -508,13 +522,20 @@ fun SearchPage(
.focusRestorer(textFieldFocusRequester) .focusRestorer(textFieldFocusRequester)
.focusRequester(focusRequesters[SEARCH_ROW]), .focusRequester(focusRequesters[SEARCH_ROW]),
) { ) {
VoiceSearchButton( AnimatedVisibility(
onSpeechResult = { spokenText -> visible = voiceSearchButtonVisible,
query = spokenText enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End),
triggerImmediateSearch(spokenText) exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End),
}, ) {
voiceInputManager = viewModel.voiceInputManager, VoiceSearchButton(
) onSpeechResult = { spokenText ->
query = spokenText
triggerImmediateSearch(spokenText)
},
voiceInputManager = viewModel.voiceInputManager,
modifier = Modifier.padding(end = 12.dp),
)
}
SearchEditTextBox( SearchEditTextBox(
value = query, value = query,
@ -547,6 +568,7 @@ fun SearchPage(
title = R.string.view_options, title = R.string.view_options,
iconStringRes = R.string.fa_sliders, iconStringRes = R.string.fa_sliders,
onClick = { showViewOptions = true }, onClick = { showViewOptions = true },
modifier = Modifier.padding(start = 12.dp),
) )
} }
} }
@ -757,6 +779,8 @@ fun SearchPage(
SearchViewOptionsDialog( SearchViewOptionsDialog(
combinedResults = combinedMode, combinedResults = combinedMode,
onCombinedResultsChange = viewModel::setCombinedResults, onCombinedResultsChange = viewModel::setCombinedResults,
voiceSearchButtonVisible = voiceSearchButtonVisible,
onVoiceSearchButtonVisibleChange = viewModel::setVoiceSearchButtonVisible,
onDismissRequest = { showViewOptions = false }, onDismissRequest = { showViewOptions = false },
) )
} }
@ -766,6 +790,8 @@ fun SearchPage(
fun SearchViewOptionsDialog( fun SearchViewOptionsDialog(
combinedResults: Boolean, combinedResults: Boolean,
onCombinedResultsChange: (Boolean) -> Unit, onCombinedResultsChange: (Boolean) -> Unit,
voiceSearchButtonVisible: Boolean,
onVoiceSearchButtonVisibleChange: (Boolean) -> Unit,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
Dialog( Dialog(
@ -815,6 +841,31 @@ fun SearchViewOptionsDialog(
onClick = { onCombinedResultsChange(!combinedResults) }, onClick = { onCombinedResultsChange(!combinedResults) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
ListItem(
selected = false,
headlineContent = {
Text(stringResource(R.string.show_voice_search_button))
},
supportingContent = {
Text(
if (voiceSearchButtonVisible) {
stringResource(R.string.visible_ui)
} else {
stringResource(R.string.hidden_ui)
},
)
},
trailingContent = {
Switch(
checked = voiceSearchButtonVisible,
onCheckedChange = onVoiceSearchButtonVisibleChange,
colors = SwitchColors(),
)
},
onClick = { onVoiceSearchButtonVisibleChange(!voiceSearchButtonVisible) },
modifier = Modifier.fillMaxWidth(),
)
} }
} }
} }

View file

@ -168,6 +168,7 @@ message ScreensaverPreferences{
message SearchPreferences { message SearchPreferences {
bool combined_search_results = 1; bool combined_search_results = 1;
bool show_voice_search_button = 2;
} }
enum DisplayToggle{ enum DisplayToggle{

View file

@ -449,6 +449,9 @@
<string name="combined_search_results">Combined search results</string> <string name="combined_search_results">Combined search results</string>
<string name="combined_search_results_on">Show all results in a single list</string> <string name="combined_search_results_on">Show all results in a single list</string>
<string name="combined_search_results_off">Show results by category</string> <string name="combined_search_results_off">Show results by category</string>
<string name="show_voice_search_button">Show voice search button</string>
<string name="visible_ui">Visible</string>
<string name="hidden_ui">Hidden</string>
<string name="results">Results</string> <string name="results">Results</string>
<string name="resolution_switching">Resolution switching</string> <string name="resolution_switching">Resolution switching</string>
<string name="local">Local</string> <string name="local">Local</string>