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(
|
||||
stringResource(R.string.discover),
|
||||
stringResource(R.string.request),
|
||||
stringResource(R.string.search),
|
||||
)
|
||||
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
|
||||
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 -> {
|
||||
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 ->
|
||||
showHeader = index < columns
|
||||
}
|
||||
val showTabs = seerrActive && query.isNotBlank() && showHeader
|
||||
val showTabs = seerrActive && query.isNotBlank() && showHeader && combinedMode
|
||||
val isLibraryTab = selectedTab == 0
|
||||
|
||||
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