diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 278c148c..47dae5ec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -66,7 +66,8 @@ class AppPreferencesSerializer directPlayPgs = AppPreference.DirectPlayPgs.defaultValue mediaExtensionsEnabled = AppPreference.FfmpegPreference.defaultValue - assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue + assPlaybackMode = + AppPreference.AssSubtitleMode.defaultValue }.build() mpvOptions = @@ -99,6 +100,13 @@ class AppPreferencesSerializer backdropStyle = AppPreference.BackdropStylePref.defaultValue showLogos = AppPreference.ShowLogos.defaultValue + searchPreferences = + SearchPreferences + .newBuilder() + .apply { + combinedSearchResults = false + }.build() + subtitlesPreferences = SubtitlePreferences .newBuilder() @@ -207,6 +215,11 @@ inline fun AppPreferences.updateInterfacePreferences(block: InterfacePreferences interfacePreferences = interfacePreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updateSearchPreferences(block: SearchPreferences.Builder.() -> Unit): AppPreferences = + updateInterfacePreferences { + searchPreferences = searchPreferences.toBuilder().apply(block).build() + } + inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.Builder.() -> Unit): AppPreferences = updateInterfacePreferences { subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 2c13c0ca..9fbf22ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -338,7 +338,7 @@ fun CardGrid( val cardWidth = ceil((width - (spacingPx * (columns - 1))) / columns) cardWidthPx = cardWidth.toInt() - Timber.v("cardWidthPx=%s", cardWidthPx) +// Timber.v("cardWidthPx=%s", cardWidthPx) }, ) { items(pager.size) { index -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 2c4f729e..28d469e9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -1,21 +1,32 @@ package com.github.damontecres.wholphin.ui.main +import android.view.Gravity import androidx.activity.compose.BackHandler import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background 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.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.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -35,52 +46,73 @@ 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.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.datastore.core.DataStore 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.ListItem import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Switch import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.preferences.updateSearchPreferences import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.EpisodeCard +import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.ItemRowTitle import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.TabRow 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.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.detail.GridItemDetails 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.preferences.SwitchColors import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.SearchRelevance import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.request.GetItemsRequest import timber.log.Timber @@ -92,12 +124,19 @@ class SearchViewModel constructor( val api: ApiClient, val navigationManager: NavigationManager, + private val appPreferences: DataStore, private val seerrService: SeerrService, val voiceInputManager: VoiceInputManager, ) : ViewModel() { val voiceState = voiceInputManager.state val soundLevel = voiceInputManager.soundLevel val partialResult = voiceInputManager.partialResult + val seerrActive = seerrService.active + + val combinedModeFlow: StateFlow = + appPreferences.data + .map { it.interfacePreferences.searchPreferences.combinedSearchResults } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) val movies = MutableLiveData(SearchResult.NoQuery) val series = MutableLiveData(SearchResult.NoQuery) @@ -107,28 +146,37 @@ class SearchViewModel val artists = MutableLiveData(SearchResult.NoQuery) val songs = MutableLiveData(SearchResult.NoQuery) val seerrResults = MutableLiveData(SearchResult.NoQuery) + val combinedResults = MutableLiveData(SearchResult.NoQuery) private var currentQuery: String? = null + private var combinedMode = false - private val semaphore = Semaphore(4) - - fun search(query: String?) { - if (currentQuery == query) { + fun search( + query: String?, + combined: Boolean = false, + ) { + if (currentQuery == query && combinedMode == combined) { return } currentQuery = query + combinedMode = combined if (query.isNotNullOrBlank()) { - movies.value = SearchResult.Searching - series.value = SearchResult.Searching - episodes.value = SearchResult.Searching - collections.value = SearchResult.Searching - searchInternal(query, BaseItemKind.MOVIE, movies) - searchInternal(query, BaseItemKind.SERIES, series) - searchInternal(query, BaseItemKind.EPISODE, episodes) - searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums) - searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists) - searchInternal(query, BaseItemKind.AUDIO, songs) - searchInternal(query, BaseItemKind.BOX_SET, collections) + if (combined) { + combinedResults.value = SearchResult.Searching + searchCombined(query) + } else { + movies.value = SearchResult.Searching + series.value = SearchResult.Searching + episodes.value = SearchResult.Searching + collections.value = SearchResult.Searching + searchInternal(query, BaseItemKind.MOVIE, movies) + searchInternal(query, BaseItemKind.SERIES, series) + searchInternal(query, BaseItemKind.EPISODE, episodes) + searchInternal(query, BaseItemKind.BOX_SET, collections) + searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums) + searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists) + searchInternal(query, BaseItemKind.AUDIO, songs) + } searchSeerr(query) } else { movies.value = SearchResult.NoQuery @@ -136,6 +184,7 @@ class SearchViewModel episodes.value = SearchResult.NoQuery collections.value = SearchResult.NoQuery seerrResults.value = SearchResult.NoQuery + combinedResults.value = SearchResult.NoQuery } } @@ -146,21 +195,26 @@ class SearchViewModel ) { viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { try { - semaphore.withPermit { - val request = - GetItemsRequest( - searchTerm = query, - recursive = true, - includeItemTypes = listOf(type), - fields = SlimItemFields, - limit = 25, - ) - val pager = - ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) - pager.init() - withContext(Dispatchers.Main) { - target.value = SearchResult.Success(pager) + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = SlimItemFields, + limit = 50, + ) + val result = api.itemsApi.getItems(request).content + val items = + (result.items ?: emptyList()).map { + BaseItem.from(it, api, false) } + val sorted = + items.sortedWith( + compareBy { SearchRelevance.score(it, query) } + .thenBy { it.name ?: "" }, + ) + withContext(Dispatchers.Main) { + target.value = SearchResult.Success(sorted) } } catch (ex: Exception) { Timber.e(ex, "Exception searching for $type") @@ -171,6 +225,56 @@ class SearchViewModel } } + private fun searchCombined(query: String) { + viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { + try { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.BOX_SET, + ), + fields = SlimItemFields, + limit = 50, + ) + + val result = api.itemsApi.getItems(request).content + val items = + (result.items ?: emptyList()).map { + BaseItem.from(it, api, false) + } + val sorted = + items.sortedWith( + compareBy { SearchRelevance.score(it, query) } + .thenBy { it.name ?: "" }, + ) + + withContext(Dispatchers.Main) { + combinedResults.value = SearchResult.Success(sorted) + } + } catch (ex: Exception) { + Timber.e(ex, "Exception in combined search") + withContext(Dispatchers.Main) { + combinedResults.value = SearchResult.Error(ex) + } + } + } + } + + fun setCombinedResults(enabled: Boolean) { + viewModelScope.launchIO { + appPreferences.updateData { + it.updateSearchPreferences { + combinedSearchResults = enabled + } + } + } + } + private fun searchSeerr(query: String) { viewModelScope.launchIO { if (seerrService.active.first()) { @@ -214,14 +318,17 @@ sealed interface SearchResult { } private const val SEARCH_ROW = 0 -private const val MOVIE_ROW = SEARCH_ROW + 1 +private const val TAB_ROW = SEARCH_ROW + 1 +private const val MOVIE_ROW = TAB_ROW + 1 private const val SERIES_ROW = MOVIE_ROW + 1 private const val EPISODE_ROW = SERIES_ROW + 1 -private const val ALBUM_ROW = EPISODE_ROW + 1 +private const val COLLECTION_ROW = EPISODE_ROW + 1 +private const val ALBUM_ROW = COLLECTION_ROW + 1 private const val ARTIST_ROW = ALBUM_ROW + 1 private const val SONG_ROW = ARTIST_ROW + 1 -private const val COLLECTION_ROW = SONG_ROW + 1 -private const val SEERR_ROW = COLLECTION_ROW + 1 +private const val SEERR_ROW = SONG_ROW + 1 + +private const val COMBINED_ROW = TAB_ROW + 1 /** Delay for focus to settle after voice search dialog dismisses. */ private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L @@ -242,10 +349,17 @@ fun SearchPage( val artists by viewModel.artists.observeAsState(SearchResult.NoQuery) val songs by viewModel.songs.observeAsState(SearchResult.NoQuery) val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery) + val combinedResults by viewModel.combinedResults.observeAsState(SearchResult.NoQuery) + val combinedMode by viewModel.combinedModeFlow.collectAsState() // val query = rememberTextFieldState() var query by rememberSaveable { mutableStateOf("") } val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } + val tabFocusRequesters = remember { List(2) { FocusRequester() } } + + val seerrActive by viewModel.seerrActive.collectAsState(initial = false) + var selectedTab by rememberSaveable { mutableIntStateOf(0) } + var showViewOptions by rememberSaveable { mutableStateOf(false) } var position by rememberPosition(0, 0) var searchClicked by rememberSaveable { mutableStateOf(false) } @@ -260,10 +374,10 @@ fun SearchPage( fun triggerImmediateSearch(searchQuery: String) { immediateSearchQuery = searchQuery searchClicked = true - viewModel.search(searchQuery) + viewModel.search(searchQuery, combinedMode) } - LaunchedEffect(query) { + LaunchedEffect(query, combinedMode) { when { immediateSearchQuery == query -> { immediateSearchQuery = null @@ -271,7 +385,7 @@ fun SearchPage( else -> { delay(750L) - viewModel.search(query) + viewModel.search(query, combinedMode) } } } @@ -281,248 +395,559 @@ fun SearchPage( val onClickItem = { index: Int, item: BaseItem -> viewModel.navigationManager.navigateTo(item.destination()) } + val onPlayItem = { _: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + } - LaunchedEffect(searchClicked, movies, collections, series, episodes, seerrResults) { + 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) + } + + var showHeader by remember { mutableStateOf(true) } + val positionCallback = { columns: Int, index: Int -> + showHeader = index < columns + } + val showTabs = seerrActive && query.isNotBlank() && showHeader + val isLibraryTab = selectedTab == 0 + + LaunchedEffect(seerrActive, query) { + if (!seerrActive || query.isBlank()) { + selectedTab = 0 + } + } + + LaunchedEffect( + searchClicked, + movies, + collections, + series, + episodes, + seerrResults, + combinedResults, + combinedMode, + selectedTab, + seerrActive, + ) { 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 results = + if (isLibraryTab) { + if (combinedMode) { + listOf(combinedResults) + } else { + listOf(movies, series, episodes, collections) + } + } else { + listOf(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() } + val targetRow = + if (isLibraryTab) { + if (combinedMode) { + COMBINED_ROW + } else { + MOVIE_ROW + firstSuccess + } + } else { + SEERR_ROW + } + position = RowColumn(targetRow, 0) + onMain { focusRequesters[targetRow].tryRequestFocus() } + } + } + } + } + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + 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) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .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 + } + }, + ) + + ExpandableFaButton( + title = R.string.view_options, + iconStringRes = R.string.fa_sliders, + onClick = { showViewOptions = true }, + ) + } + } + AnimatedVisibility( + visible = showTabs, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + TabRow( + selectedTabIndex = selectedTab, + tabs = + listOf( + stringResource(R.string.library), + stringResource(R.string.discover), + ), + focusRequesters = tabFocusRequesters, + onClick = { + selectedTab = it + val row = + when (selectedTab) { + 0 -> COMBINED_ROW + else -> SEERR_ROW + } + position = RowColumn(row, 0) + }, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp), + ) + } + Box( + modifier = Modifier.fillMaxSize(), + ) { + SideEffect { + Timber.v("isLibraryTab=%s, combinedMode=%s", isLibraryTab, combinedMode) + } + when { + isLibraryTab && combinedMode -> { + SearchCombinedResults( + result = combinedResults, + focusRequester = focusRequesters[COMBINED_ROW], + onClickItem = onClickItem, + onPlayItem = onPlayItem, + onClickPosition = { position = it }, + onClickDiscover = onClickDiscover, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + ) + } + + !isLibraryTab && combinedMode -> { + SearchCombinedResults( + result = seerrResults, + focusRequester = focusRequesters[SEERR_ROW], + onClickItem = onClickItem, + onPlayItem = onPlayItem, + onClickPosition = { position = it }, + onClickDiscover = onClickDiscover, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + ) + } + + isLibraryTab -> { + LazyColumn( + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 44.dp, + ), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier.focusGroup(), + ) { + searchResultRow( + title = R.string.movies, + result = movies, + rowIndex = MOVIE_ROW, + position = position, + focusRequester = focusRequesters[MOVIE_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) + searchResultRow( + title = R.string.tv_shows, + result = series, + rowIndex = SERIES_ROW, + position = position, + focusRequester = focusRequesters[SERIES_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) + searchResultRow( + title = R.string.episodes, + result = episodes, + rowIndex = EPISODE_ROW, + position = position, + focusRequester = focusRequesters[EPISODE_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + cardContent = @Composable { index, item, mod, onClick, onLongClick -> + EpisodeCard( + item = item, + onClick = { + position = RowColumn(EPISODE_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = 140.dp, + modifier = mod.padding(horizontal = 8.dp), + ) + }, + ) + searchResultRow( + title = R.string.collections, + result = collections, + rowIndex = COLLECTION_ROW, + position = position, + focusRequester = focusRequesters[COLLECTION_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) + searchResultRow( + title = R.string.albums, + result = albums, + rowIndex = ALBUM_ROW, + position = position, + focusRequester = focusRequesters[ALBUM_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(ALBUM_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + showImageOverlay = true, + modifier = mod, + ) + }, + ) + searchResultRow( + title = R.string.artists, + result = artists, + rowIndex = ARTIST_ROW, + position = position, + focusRequester = focusRequesters[ARTIST_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(ARTIST_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + showImageOverlay = true, + modifier = mod, + ) + }, + ) + searchResultRow( + title = R.string.songs, + result = songs, + rowIndex = SONG_ROW, + position = position, + focusRequester = focusRequesters[SONG_ROW], + onClickItem = onClickItem, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(SONG_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + showImageOverlay = true, + modifier = mod, + ) + }, + ) + } } } } } - LazyColumn( - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier.focusGroup(), + if (showViewOptions) { + SearchViewOptionsDialog( + combinedResults = combinedMode, + onCombinedResultsChange = viewModel::setCombinedResults, + onDismissRequest = { showViewOptions = false }, + ) + } +} + +@Composable +fun SearchViewOptionsDialog( + combinedResults: Boolean, + onCombinedResultsChange: (Boolean) -> Unit, + onDismissRequest: () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), ) { - item { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth(), - ) { - var isSearchActive by remember { mutableStateOf(false) } - var isTextFieldFocused by remember { mutableStateOf(false) } - val textFieldFocusRequester = remember { FocusRequester() } + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.setGravity(Gravity.CENTER) - BackHandler(isTextFieldFocused) { - when { - isSearchActive -> { - isSearchActive = false - keyboardController?.hide() - } + Box( + modifier = + Modifier + .width(400.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + RoundedCornerShape(28.dp), + ).padding(24.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Text( + text = stringResource(R.string.view_options), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) - else -> { - focusManager.moveFocus(FocusDirection.Next) - } - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .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 - } - }, - ) - } + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.combined_search_results)) + }, + supportingContent = { + Text( + if (combinedResults) { + stringResource(R.string.combined_search_results_on) + } else { + stringResource(R.string.combined_search_results_off) + }, + ) + }, + trailingContent = { + Switch( + checked = combinedResults, + onCheckedChange = onCombinedResultsChange, + colors = SwitchColors(), + ) + }, + onClick = { onCombinedResultsChange(!combinedResults) }, + modifier = Modifier.fillMaxWidth(), + ) } } - searchResultRow( - title = R.string.movies, - result = movies, - rowIndex = MOVIE_ROW, - position = position, - focusRequester = focusRequesters[MOVIE_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.tv_shows, - result = series, - rowIndex = SERIES_ROW, - position = position, - focusRequester = focusRequesters[SERIES_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.episodes, - result = episodes, - rowIndex = EPISODE_ROW, - position = position, - focusRequester = focusRequesters[EPISODE_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = @Composable { index, item, mod, onClick, onLongClick -> - EpisodeCard( - item = item, - onClick = { - position = RowColumn(EPISODE_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = 140.dp, - showImageOverlay = true, - modifier = mod.padding(horizontal = 8.dp), + } +} + +@Composable +fun SearchCombinedResults( + result: SearchResult, + focusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onPlayItem: (Int, BaseItem) -> Unit, + onClickPosition: (RowColumn) -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, + positionCallback: (columns: Int, position: Int) -> Unit, + modifier: Modifier = Modifier, +) { + when (val r = result) { + SearchResult.NoQuery -> { + Unit + } + + SearchResult.Searching -> { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.searching), + modifier = modifier.padding(16.dp), + ) + } + + is SearchResult.Error -> { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = r.ex.localizedMessage ?: "Error occurred during search", + messageColor = MaterialTheme.colorScheme.error, + modifier = modifier.padding(16.dp), + ) + } + + is SearchResult.Success -> { + if (r.items.isEmpty()) { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.no_results), + modifier = modifier.padding(16.dp), ) - }, - ) - searchResultRow( - title = R.string.albums, - result = albums, - rowIndex = ALBUM_ROW, - position = position, - focusRequester = focusRequesters[ALBUM_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(ALBUM_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.artists, - result = artists, - rowIndex = COLLECTION_ROW, - position = position, - focusRequester = focusRequesters[COLLECTION_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(ALBUM_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.songs, - result = songs, - rowIndex = SONG_ROW, - position = position, - focusRequester = focusRequesters[SONG_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - position = RowColumn(ALBUM_ROW, index) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.collections, - result = collections, - rowIndex = COLLECTION_ROW, - position = position, - focusRequester = focusRequesters[COLLECTION_ROW], - onClickItem = onClickItem, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.discover, - result = seerrResults, - rowIndex = SEERR_ROW, - position = position, - focusRequester = focusRequesters[SEERR_ROW], - onClickItem = { _, _ -> - // no-op - }, - onClickDiscover = { _, item -> - val dest = - if (item.jellyfinItemId != null && item.type.baseItemKind != null) { - Destination.MediaItem( - itemId = item.jellyfinItemId, - type = item.type.baseItemKind, + } else { + SearchGrid( + items = r.items, + focusRequester = focusRequester, + onClickItem = onClickItem, + onPlayItem = onPlayItem, + onClickPosition = onClickPosition, + positionCallback = positionCallback, + cardContent = { details -> + GridCard( + item = details.item, + onClick = details.onClick, + onLongClick = details.onLongClick, + modifier = details.mod, + fillWidth = details.widthPx, ) - } else { - Destination.DiscoveredItem(item) - } - viewModel.navigationManager.navigateTo(dest) + }, + modifier = modifier, + ) + } + } + + is SearchResult.SuccessSeerr -> { + if (r.items.isEmpty()) { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.no_results), + modifier = modifier.padding(16.dp), + ) + } else { + SearchGrid( + items = r.items, + focusRequester = focusRequester, + onClickItem = onClickDiscover, + onPlayItem = { _, _ -> }, + onClickPosition = onClickPosition, + positionCallback = positionCallback, + cardContent = { details -> + DiscoverItemCard( + item = details.item, + onClick = details.onClick, + onLongClick = details.onLongClick, + modifier = details.mod, + ) + }, + modifier = modifier, + ) + } + } + } +} + +@Composable +private fun SearchGrid( + items: List, + focusRequester: FocusRequester, + onClickItem: (Int, T) -> Unit, + onPlayItem: (Int, T) -> Unit, + onClickPosition: (RowColumn) -> Unit, + cardContent: @Composable (GridItemDetails) -> Unit, + positionCallback: (columns: Int, position: Int) -> Unit, + modifier: Modifier = Modifier, +) { + RequestOrRestoreFocus(focusRequester) + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = modifier, + ) { + ItemRowTitle(stringResource(R.string.results)) + + CardGrid( + pager = items, + onClickItem = { index, item -> + onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) + onClickItem.invoke(index, item) }, - onClickPosition = { position = it }, - modifier = Modifier.fillMaxWidth(), + onLongClickItem = { _, _ -> }, + onClickPlay = { index, item -> + onClickPosition.invoke(RowColumn(COMBINED_ROW, index)) + onPlayItem.invoke(index, item) + }, + letterPosition = { -1 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + cardContent = cardContent, ) } } @@ -581,13 +1006,7 @@ fun LazyListScope.searchResultRow( } is SearchResult.Success -> { - if (r.items.isEmpty()) { - SearchResultPlaceholder( - title = stringResource(title), - message = stringResource(R.string.no_results), - modifier = modifier, - ) - } else { + if (r.items.isNotEmpty()) { ItemRow( title = stringResource(title), items = r.items, @@ -600,13 +1019,7 @@ fun LazyListScope.searchResultRow( } is SearchResult.SuccessSeerr -> { - if (r.items.isEmpty()) { - SearchResultPlaceholder( - title = stringResource(title), - message = stringResource(R.string.no_results), - modifier = modifier, - ) - } else { + if (r.items.isNotEmpty()) { ItemRow( title = stringResource(title), items = r.items, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt b/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt new file mode 100644 index 00000000..60dada9d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/SearchRelevance.kt @@ -0,0 +1,89 @@ +package com.github.damontecres.wholphin.util + +import com.github.damontecres.wholphin.data.model.BaseItem +import org.jellyfin.sdk.model.api.BaseItemKind +import java.util.Locale + +object SearchRelevance { + private val REGEX_PATTERN = Regex("^/(.+)/(i)?$") + + fun score( + item: BaseItem, + query: String, + ): Int { + val name = item.name?.lowercase(Locale.getDefault()) ?: return Int.MAX_VALUE + val q = query.lowercase(Locale.getDefault()) + + REGEX_PATTERN.find(query)?.let { match -> + val pattern = match.groupValues[1] + val caseInsensitive = match.groupValues[2] == "i" + return regexScore(item.name ?: "", pattern, caseInsensitive) + typeBonus(item.type) + } + + return when { + name == q -> 0 + name.startsWith(q) -> 100 + name.contains(" $q") -> 200 + name.contains(q) -> 300 + else -> fuzzyScore(name, q) + } + typeBonus(item.type) + } + + private fun regexScore( + name: String, + pattern: String, + caseInsensitive: Boolean, + ): Int = + try { + val options = if (caseInsensitive) setOf(RegexOption.IGNORE_CASE) else emptySet() + if (Regex(pattern, options).containsMatchIn(name)) 250 else 400 + } catch (e: Exception) { + 400 + } + + private fun fuzzyScore( + name: String, + query: String, + ): Int { + val distance = levenshteinDistance(name, query) + val maxLen = maxOf(name.length, query.length) + val similarity = 1.0 - (distance.toDouble() / maxLen) + + return when { + similarity >= 0.8 -> 350 + similarity >= 0.6 -> 375 + else -> 400 + } + } + + private fun levenshteinDistance( + s1: String, + s2: String, + ): Int { + val m = s1.length + val n = s2.length + val dp = Array(m + 1) { IntArray(n + 1) } + for (i in 0..m) dp[i][0] = i + for (j in 0..n) dp[0][j] = j + for (i in 1..m) { + for (j in 1..n) { + dp[i][j] = + if (s1[i - 1] == s2[j - 1]) { + dp[i - 1][j - 1] + } else { + 1 + minOf(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + } + } + } + return dp[m][n] + } + + private fun typeBonus(type: BaseItemKind): Int = + when (type) { + BaseItemKind.SERIES -> 0 + BaseItemKind.MOVIE -> 1 + BaseItemKind.BOX_SET -> 2 + BaseItemKind.EPISODE -> 10 + else -> 5 + } +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 69df8689..a29c59ac 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -165,6 +165,10 @@ message ScreensaverPreferences{ bool show_clock = 7; } +message SearchPreferences { + bool combined_search_results = 1; +} + message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; @@ -179,6 +183,7 @@ message InterfacePreferences { ScreensaverPreferences screensaver_preference = 11; bool enable_media_management = 12; bool show_logos = 13; + SearchPreferences search_preferences = 14; } message AdvancedPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9b56865..15ab0182 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -445,6 +445,10 @@ Color-code programs Subtitle delay Backdrop style + Combined search results + Show all results in a single list + Show results by category + Results Resolution switching Local Play trailer