mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Search fixes & improvements (#1435)
## Description Fixes showing the discover search result row Also adds a Search tab to the discover page. You can already search discover results from the regular search page, so the new tab is just for convenience. ### Related issues Related to #709 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None
This commit is contained in:
parent
6f11a3711f
commit
c3ba14557c
3 changed files with 271 additions and 1 deletions
|
|
@ -41,6 +41,7 @@ fun DiscoverPage(
|
||||||
listOf(
|
listOf(
|
||||||
stringResource(R.string.discover),
|
stringResource(R.string.discover),
|
||||||
stringResource(R.string.request),
|
stringResource(R.string.request),
|
||||||
|
stringResource(R.string.search),
|
||||||
)
|
)
|
||||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||||
val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
|
val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } }
|
||||||
|
|
@ -92,6 +93,17 @@ fun DiscoverPage(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
2 -> {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
preferencesViewModel.backdropService.clearBackdrop()
|
||||||
|
}
|
||||||
|
DiscoverSearchPage(
|
||||||
|
preferences = preferences,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
ErrorMessage("Invalid tab index $selectedTabIndex", null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,248 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.discover
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
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.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
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.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.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||||
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
|
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.launchIO
|
||||||
|
import com.github.damontecres.wholphin.ui.main.SearchCombinedResults
|
||||||
|
import com.github.damontecres.wholphin.ui.main.SearchResult
|
||||||
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class DiscoverSearchViewModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
val navigationManager: NavigationManager,
|
||||||
|
private val seerrService: SeerrService,
|
||||||
|
val voiceInputManager: VoiceInputManager,
|
||||||
|
val userPreferencesService: UserPreferencesService,
|
||||||
|
) : ViewModel() {
|
||||||
|
val seerrResults = MutableStateFlow<SearchResult>(SearchResult.NoQuery)
|
||||||
|
|
||||||
|
private var searchJob: Job? = null
|
||||||
|
var currentQuery: String = ""
|
||||||
|
|
||||||
|
fun search(query: String) {
|
||||||
|
if (query == currentQuery) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentQuery = query
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
if (query.isBlank()) {
|
||||||
|
seerrResults.value = SearchResult.NoQuery
|
||||||
|
return@launchIO
|
||||||
|
}
|
||||||
|
Timber.v("Starting seerr search")
|
||||||
|
seerrResults.value = SearchResult.Searching
|
||||||
|
val results =
|
||||||
|
seerrService
|
||||||
|
.search(query)
|
||||||
|
.map { seerrService.createDiscoverItem(it) }
|
||||||
|
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
|
||||||
|
Timber.v("Seerr search complete: %s results", results.size)
|
||||||
|
seerrResults.value = SearchResult.SuccessSeerr(results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DiscoverSearchPage(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: DiscoverSearchViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val seerrResults by viewModel.seerrResults.collectAsState()
|
||||||
|
var immediateSearchQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var query by rememberSaveable { mutableStateOf(viewModel.currentQuery) }
|
||||||
|
var position by rememberInt(-1)
|
||||||
|
|
||||||
|
// Start with current preferences, but collect updates when view options change
|
||||||
|
val prefs =
|
||||||
|
viewModel.userPreferencesService.flow
|
||||||
|
.collectAsState(preferences)
|
||||||
|
.value.appPreferences.interfacePreferences.searchPreferences
|
||||||
|
|
||||||
|
val onClickDiscover = { _: Int, item: DiscoverItem ->
|
||||||
|
val dest =
|
||||||
|
if (item.jellyfinItemId != null && item.type.baseItemKind != null) {
|
||||||
|
Destination.MediaItem(
|
||||||
|
itemId = item.jellyfinItemId,
|
||||||
|
type = item.type.baseItemKind,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Destination.DiscoveredItem(item)
|
||||||
|
}
|
||||||
|
viewModel.navigationManager.navigateTo(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun triggerImmediateSearch(searchQuery: String) {
|
||||||
|
immediateSearchQuery = searchQuery
|
||||||
|
viewModel.search(searchQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(query) {
|
||||||
|
when {
|
||||||
|
immediateSearchQuery == query -> {
|
||||||
|
immediateSearchQuery = null
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
delay(750L)
|
||||||
|
viewModel.search(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val gridFocusRequester = remember { FocusRequester() }
|
||||||
|
val textFieldFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (position >= 0) {
|
||||||
|
gridFocusRequester.tryRequestFocus("grid")
|
||||||
|
} else {
|
||||||
|
textFieldFocusRequester.tryRequestFocus("textField")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
|
var isTextFieldFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
BackHandler(isTextFieldFocused) {
|
||||||
|
when {
|
||||||
|
isSearchActive -> {
|
||||||
|
isSearchActive = false
|
||||||
|
keyboardController?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
focusManager.moveFocus(FocusDirection.Next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 16.dp, end = 16.dp)
|
||||||
|
.focusGroup()
|
||||||
|
.focusRestorer()
|
||||||
|
.focusRequester(textFieldFocusRequester),
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = prefs.showVoiceSearchButton,
|
||||||
|
enter = fadeIn() + expandHorizontally(expandFrom = Alignment.End),
|
||||||
|
exit = fadeOut() + shrinkHorizontally(shrinkTowards = Alignment.End),
|
||||||
|
) {
|
||||||
|
VoiceSearchButton(
|
||||||
|
onSpeechResult = { spokenText ->
|
||||||
|
query = spokenText
|
||||||
|
triggerImmediateSearch(spokenText)
|
||||||
|
},
|
||||||
|
voiceInputManager = viewModel.voiceInputManager,
|
||||||
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchEditTextBox(
|
||||||
|
value = query,
|
||||||
|
onValueChange = {
|
||||||
|
isSearchActive = true
|
||||||
|
query = it
|
||||||
|
},
|
||||||
|
onSearchClick = { triggerImmediateSearch(query) },
|
||||||
|
readOnly = !isSearchActive,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SearchCombinedResults(
|
||||||
|
result = seerrResults,
|
||||||
|
focusRequester = gridFocusRequester,
|
||||||
|
onClickItem = { _, _ -> },
|
||||||
|
onPlayItem = { _, _ -> },
|
||||||
|
onClickPosition = { position = it.column },
|
||||||
|
onClickDiscover = onClickDiscover,
|
||||||
|
positionCallback = { columns, index -> position = index },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -430,7 +430,7 @@ fun SearchPage(
|
||||||
val positionCallback = { columns: Int, index: Int ->
|
val positionCallback = { columns: Int, index: Int ->
|
||||||
showHeader = index < columns
|
showHeader = index < columns
|
||||||
}
|
}
|
||||||
val showTabs = seerrActive && query.isNotBlank() && showHeader
|
val showTabs = seerrActive && query.isNotBlank() && showHeader && combinedMode
|
||||||
val isLibraryTab = selectedTab == 0
|
val isLibraryTab = selectedTab == 0
|
||||||
|
|
||||||
LaunchedEffect(seerrActive, query) {
|
LaunchedEffect(seerrActive, query) {
|
||||||
|
|
@ -769,6 +769,16 @@ fun SearchPage(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
searchResultRow(
|
||||||
|
title = R.string.discover,
|
||||||
|
result = seerrResults,
|
||||||
|
rowIndex = SEERR_ROW,
|
||||||
|
position = position,
|
||||||
|
focusRequester = focusRequesters[SEERR_ROW],
|
||||||
|
onClickItem = onClickItem,
|
||||||
|
onClickPosition = { position = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue