mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Search page improvements (#709)
## Description Improves how search results are displayed: - Reorders search categories to Movies → TV Shows → Episodes → Collections, surfacing the most commonly searched content types at the top. - Adds a new "Combined search results" preference in Advanced settings → Search. When enabled, shows all results in a single list instead of separate categories. - When Seerr is active, adds Library/Discover tabs to switch between local library results and Seerr discovery results. - Hides empty result categories instead of showing "No results" placeholders. ### Screenshots <img width="1935" height="1100" alt="Screenshot_20260116_113656" src="https://github.com/user-attachments/assets/1e29aa3a-3ba2-4f86-ab61-fed683490474" /> <img width="1934" height="1095" alt="image" src="https://github.com/user-attachments/assets/8b93fa38-d4c9-4e72-a997-caa82b4949f0" /> <img width="1921" height="1094" alt="image" src="https://github.com/user-attachments/assets/82aed1a5-810c-413f-a266-6d78490a1534" /> ### AI/LLM usage Code auditing and PR description drafting with Claude Code. Also used Claude Code for the regex.
This commit is contained in:
parent
f364ef9708
commit
98ffaf51ac
6 changed files with 798 additions and 274 deletions
|
|
@ -66,7 +66,8 @@ class AppPreferencesSerializer
|
||||||
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
|
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
|
||||||
mediaExtensionsEnabled =
|
mediaExtensionsEnabled =
|
||||||
AppPreference.FfmpegPreference.defaultValue
|
AppPreference.FfmpegPreference.defaultValue
|
||||||
assPlaybackMode = AppPreference.AssSubtitleMode.defaultValue
|
assPlaybackMode =
|
||||||
|
AppPreference.AssSubtitleMode.defaultValue
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
mpvOptions =
|
mpvOptions =
|
||||||
|
|
@ -99,6 +100,13 @@ class AppPreferencesSerializer
|
||||||
backdropStyle = AppPreference.BackdropStylePref.defaultValue
|
backdropStyle = AppPreference.BackdropStylePref.defaultValue
|
||||||
showLogos = AppPreference.ShowLogos.defaultValue
|
showLogos = AppPreference.ShowLogos.defaultValue
|
||||||
|
|
||||||
|
searchPreferences =
|
||||||
|
SearchPreferences
|
||||||
|
.newBuilder()
|
||||||
|
.apply {
|
||||||
|
combinedSearchResults = false
|
||||||
|
}.build()
|
||||||
|
|
||||||
subtitlesPreferences =
|
subtitlesPreferences =
|
||||||
SubtitlePreferences
|
SubtitlePreferences
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
|
|
@ -207,6 +215,11 @@ inline fun AppPreferences.updateInterfacePreferences(block: InterfacePreferences
|
||||||
interfacePreferences = interfacePreferences.toBuilder().apply(block).build()
|
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 =
|
inline fun AppPreferences.updateSubtitlePreferences(block: SubtitlePreferences.Builder.() -> Unit): AppPreferences =
|
||||||
updateInterfacePreferences {
|
updateInterfacePreferences {
|
||||||
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build()
|
subtitlesPreferences = subtitlesPreferences.toBuilder().apply(block).build()
|
||||||
|
|
|
||||||
|
|
@ -338,7 +338,7 @@ fun <T : CardGridItem> CardGrid(
|
||||||
val cardWidth =
|
val cardWidth =
|
||||||
ceil((width - (spacingPx * (columns - 1))) / columns)
|
ceil((width - (spacingPx * (columns - 1))) / columns)
|
||||||
cardWidthPx = cardWidth.toInt()
|
cardWidthPx = cardWidth.toInt()
|
||||||
Timber.v("cardWidthPx=%s", cardWidthPx)
|
// Timber.v("cardWidthPx=%s", cardWidthPx)
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
items(pager.size) { index ->
|
items(pager.size) { index ->
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,32 @@
|
||||||
package com.github.damontecres.wholphin.ui.main
|
package com.github.damontecres.wholphin.ui.main
|
||||||
|
|
||||||
|
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.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
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.input.key.type
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
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.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.tv.material3.ListItem
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Switch
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
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.UserPreferences
|
||||||
|
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.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.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard
|
||||||
import com.github.damontecres.wholphin.ui.cards.EpisodeCard
|
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.ItemRow
|
||||||
|
import com.github.damontecres.wholphin.ui.cards.ItemRowTitle
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
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.SearchEditTextBox
|
||||||
|
import com.github.damontecres.wholphin.ui.components.TabRow
|
||||||
import com.github.damontecres.wholphin.ui.components.VoiceInputManager
|
import com.github.damontecres.wholphin.ui.components.VoiceInputManager
|
||||||
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
|
import com.github.damontecres.wholphin.ui.components.VoiceSearchButton
|
||||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
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.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.onMain
|
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.rememberPosition
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
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.ExceptionHandler
|
||||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
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.sync.Semaphore
|
|
||||||
import kotlinx.coroutines.sync.withPermit
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
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.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
@ -92,12 +124,19 @@ class SearchViewModel
|
||||||
constructor(
|
constructor(
|
||||||
val api: ApiClient,
|
val api: ApiClient,
|
||||||
val navigationManager: NavigationManager,
|
val navigationManager: NavigationManager,
|
||||||
|
private val appPreferences: DataStore<AppPreferences>,
|
||||||
private val seerrService: SeerrService,
|
private val seerrService: SeerrService,
|
||||||
val voiceInputManager: VoiceInputManager,
|
val voiceInputManager: VoiceInputManager,
|
||||||
) : 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 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)
|
||||||
|
|
@ -107,17 +146,25 @@ class SearchViewModel
|
||||||
val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val artists = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val songs = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
val seerrResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
|
val combinedResults = MutableLiveData<SearchResult>(SearchResult.NoQuery)
|
||||||
|
|
||||||
private var currentQuery: String? = null
|
private var currentQuery: String? = null
|
||||||
|
private var combinedMode = false
|
||||||
|
|
||||||
private val semaphore = Semaphore(4)
|
fun search(
|
||||||
|
query: String?,
|
||||||
fun search(query: String?) {
|
combined: Boolean = false,
|
||||||
if (currentQuery == query) {
|
) {
|
||||||
|
if (currentQuery == query && combinedMode == combined) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentQuery = query
|
currentQuery = query
|
||||||
|
combinedMode = combined
|
||||||
if (query.isNotNullOrBlank()) {
|
if (query.isNotNullOrBlank()) {
|
||||||
|
if (combined) {
|
||||||
|
combinedResults.value = SearchResult.Searching
|
||||||
|
searchCombined(query)
|
||||||
|
} else {
|
||||||
movies.value = SearchResult.Searching
|
movies.value = SearchResult.Searching
|
||||||
series.value = SearchResult.Searching
|
series.value = SearchResult.Searching
|
||||||
episodes.value = SearchResult.Searching
|
episodes.value = SearchResult.Searching
|
||||||
|
|
@ -125,10 +172,11 @@ class SearchViewModel
|
||||||
searchInternal(query, BaseItemKind.MOVIE, movies)
|
searchInternal(query, BaseItemKind.MOVIE, movies)
|
||||||
searchInternal(query, BaseItemKind.SERIES, series)
|
searchInternal(query, BaseItemKind.SERIES, series)
|
||||||
searchInternal(query, BaseItemKind.EPISODE, episodes)
|
searchInternal(query, BaseItemKind.EPISODE, episodes)
|
||||||
|
searchInternal(query, BaseItemKind.BOX_SET, collections)
|
||||||
searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums)
|
searchInternal(query, BaseItemKind.MUSIC_ALBUM, albums)
|
||||||
searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists)
|
searchInternal(query, BaseItemKind.MUSIC_ARTIST, artists)
|
||||||
searchInternal(query, BaseItemKind.AUDIO, songs)
|
searchInternal(query, BaseItemKind.AUDIO, songs)
|
||||||
searchInternal(query, BaseItemKind.BOX_SET, collections)
|
}
|
||||||
searchSeerr(query)
|
searchSeerr(query)
|
||||||
} else {
|
} else {
|
||||||
movies.value = SearchResult.NoQuery
|
movies.value = SearchResult.NoQuery
|
||||||
|
|
@ -136,6 +184,7 @@ class SearchViewModel
|
||||||
episodes.value = SearchResult.NoQuery
|
episodes.value = SearchResult.NoQuery
|
||||||
collections.value = SearchResult.NoQuery
|
collections.value = SearchResult.NoQuery
|
||||||
seerrResults.value = SearchResult.NoQuery
|
seerrResults.value = SearchResult.NoQuery
|
||||||
|
combinedResults.value = SearchResult.NoQuery
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,21 +195,26 @@ class SearchViewModel
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
semaphore.withPermit {
|
|
||||||
val request =
|
val request =
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
searchTerm = query,
|
searchTerm = query,
|
||||||
recursive = true,
|
recursive = true,
|
||||||
includeItemTypes = listOf(type),
|
includeItemTypes = listOf(type),
|
||||||
fields = SlimItemFields,
|
fields = SlimItemFields,
|
||||||
limit = 25,
|
limit = 50,
|
||||||
)
|
)
|
||||||
val pager =
|
val result = api.itemsApi.getItems(request).content
|
||||||
ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope)
|
val items =
|
||||||
pager.init()
|
(result.items ?: emptyList()).map {
|
||||||
withContext(Dispatchers.Main) {
|
BaseItem.from(it, api, false)
|
||||||
target.value = SearchResult.Success(pager)
|
|
||||||
}
|
}
|
||||||
|
val sorted =
|
||||||
|
items.sortedWith(
|
||||||
|
compareBy<BaseItem> { SearchRelevance.score(it, query) }
|
||||||
|
.thenBy { it.name ?: "" },
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
target.value = SearchResult.Success(sorted)
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Timber.e(ex, "Exception searching for $type")
|
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<BaseItem> { 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) {
|
private fun searchSeerr(query: String) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
if (seerrService.active.first()) {
|
if (seerrService.active.first()) {
|
||||||
|
|
@ -214,14 +318,17 @@ sealed interface SearchResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val SEARCH_ROW = 0
|
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 SERIES_ROW = MOVIE_ROW + 1
|
||||||
private const val EPISODE_ROW = SERIES_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 ARTIST_ROW = ALBUM_ROW + 1
|
||||||
private const val SONG_ROW = ARTIST_ROW + 1
|
private const val SONG_ROW = ARTIST_ROW + 1
|
||||||
private const val COLLECTION_ROW = SONG_ROW + 1
|
private const val SEERR_ROW = SONG_ROW + 1
|
||||||
private const val SEERR_ROW = COLLECTION_ROW + 1
|
|
||||||
|
private const val COMBINED_ROW = TAB_ROW + 1
|
||||||
|
|
||||||
/** Delay for focus to settle after voice search dialog dismisses. */
|
/** Delay for focus to settle after voice search dialog dismisses. */
|
||||||
private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L
|
private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L
|
||||||
|
|
@ -242,10 +349,17 @@ fun SearchPage(
|
||||||
val artists by viewModel.artists.observeAsState(SearchResult.NoQuery)
|
val artists by viewModel.artists.observeAsState(SearchResult.NoQuery)
|
||||||
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 combinedMode by viewModel.combinedModeFlow.collectAsState()
|
||||||
|
|
||||||
// val query = rememberTextFieldState()
|
// val query = rememberTextFieldState()
|
||||||
var query by rememberSaveable { mutableStateOf("") }
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } }
|
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 position by rememberPosition(0, 0)
|
||||||
var searchClicked by rememberSaveable { mutableStateOf(false) }
|
var searchClicked by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
@ -260,10 +374,10 @@ fun SearchPage(
|
||||||
fun triggerImmediateSearch(searchQuery: String) {
|
fun triggerImmediateSearch(searchQuery: String) {
|
||||||
immediateSearchQuery = searchQuery
|
immediateSearchQuery = searchQuery
|
||||||
searchClicked = true
|
searchClicked = true
|
||||||
viewModel.search(searchQuery)
|
viewModel.search(searchQuery, combinedMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(query) {
|
LaunchedEffect(query, combinedMode) {
|
||||||
when {
|
when {
|
||||||
immediateSearchQuery == query -> {
|
immediateSearchQuery == query -> {
|
||||||
immediateSearchQuery = null
|
immediateSearchQuery = null
|
||||||
|
|
@ -271,7 +385,7 @@ fun SearchPage(
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
delay(750L)
|
delay(750L)
|
||||||
viewModel.search(query)
|
viewModel.search(query, combinedMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -281,33 +395,88 @@ fun SearchPage(
|
||||||
val onClickItem = { index: Int, item: BaseItem ->
|
val onClickItem = { index: Int, item: BaseItem ->
|
||||||
viewModel.navigationManager.navigateTo(item.destination())
|
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
|
if (!searchClicked) return@LaunchedEffect
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
// Want to focus on the first successful row after all of the ones before it are finished searching
|
// 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 =
|
val firstSuccess =
|
||||||
results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr }
|
results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr }
|
||||||
if (firstSuccess >= 0) {
|
if (firstSuccess >= 0) {
|
||||||
val anyBeforeSearching =
|
val anyBeforeSearching =
|
||||||
results.subList(0, firstSuccess).any { it is SearchResult.Searching }
|
results.subList(0, firstSuccess).any { it is SearchResult.Searching }
|
||||||
if (!anyBeforeSearching) {
|
if (!anyBeforeSearching) {
|
||||||
// 0-th row is the search bar
|
val targetRow =
|
||||||
position = RowColumn(firstSuccess + 1, 0)
|
if (isLibraryTab) {
|
||||||
onMain { focusRequesters[firstSuccess + 1].tryRequestFocus() }
|
if (combinedMode) {
|
||||||
|
COMBINED_ROW
|
||||||
|
} else {
|
||||||
|
MOVIE_ROW + firstSuccess
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SEERR_ROW
|
||||||
|
}
|
||||||
|
position = RowColumn(targetRow, 0)
|
||||||
|
onMain { focusRequesters[targetRow].tryRequestFocus() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Column(
|
||||||
LazyColumn(
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 44.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
modifier = modifier.focusGroup(),
|
|
||||||
) {
|
) {
|
||||||
item {
|
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -334,6 +503,7 @@ fun SearchPage(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
|
||||||
.focusGroup()
|
.focusGroup()
|
||||||
.focusRestorer(textFieldFocusRequester)
|
.focusRestorer(textFieldFocusRequester)
|
||||||
.focusRequester(focusRequesters[SEARCH_ROW]),
|
.focusRequester(focusRequesters[SEARCH_ROW]),
|
||||||
|
|
@ -372,9 +542,87 @@ fun SearchPage(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(
|
searchResultRow(
|
||||||
title = R.string.movies,
|
title = R.string.movies,
|
||||||
result = movies,
|
result = movies,
|
||||||
|
|
@ -413,11 +661,20 @@ fun SearchPage(
|
||||||
},
|
},
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
imageHeight = 140.dp,
|
imageHeight = 140.dp,
|
||||||
showImageOverlay = true,
|
|
||||||
modifier = mod.padding(horizontal = 8.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(
|
searchResultRow(
|
||||||
title = R.string.albums,
|
title = R.string.albums,
|
||||||
result = albums,
|
result = albums,
|
||||||
|
|
@ -445,9 +702,9 @@ fun SearchPage(
|
||||||
searchResultRow(
|
searchResultRow(
|
||||||
title = R.string.artists,
|
title = R.string.artists,
|
||||||
result = artists,
|
result = artists,
|
||||||
rowIndex = COLLECTION_ROW,
|
rowIndex = ARTIST_ROW,
|
||||||
position = position,
|
position = position,
|
||||||
focusRequester = focusRequesters[COLLECTION_ROW],
|
focusRequester = focusRequesters[ARTIST_ROW],
|
||||||
onClickItem = onClickItem,
|
onClickItem = onClickItem,
|
||||||
onClickPosition = { position = it },
|
onClickPosition = { position = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -455,7 +712,7 @@ fun SearchPage(
|
||||||
SeasonCard(
|
SeasonCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = {
|
onClick = {
|
||||||
position = RowColumn(ALBUM_ROW, index)
|
position = RowColumn(ARTIST_ROW, index)
|
||||||
onClick.invoke()
|
onClick.invoke()
|
||||||
},
|
},
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
|
@ -479,7 +736,7 @@ fun SearchPage(
|
||||||
SeasonCard(
|
SeasonCard(
|
||||||
item = item,
|
item = item,
|
||||||
onClick = {
|
onClick = {
|
||||||
position = RowColumn(ALBUM_ROW, index)
|
position = RowColumn(SONG_ROW, index)
|
||||||
onClick.invoke()
|
onClick.invoke()
|
||||||
},
|
},
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
|
@ -490,39 +747,207 @@ fun SearchPage(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
searchResultRow(
|
}
|
||||||
title = R.string.collections,
|
}
|
||||||
result = collections,
|
}
|
||||||
rowIndex = COLLECTION_ROW,
|
}
|
||||||
position = position,
|
}
|
||||||
focusRequester = focusRequesters[COLLECTION_ROW],
|
|
||||||
onClickItem = onClickItem,
|
if (showViewOptions) {
|
||||||
onClickPosition = { position = it },
|
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),
|
||||||
|
) {
|
||||||
|
val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider
|
||||||
|
dialogWindowProvider?.window?.setGravity(Gravity.CENTER)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
searchResultRow(
|
}
|
||||||
title = R.string.discover,
|
}
|
||||||
result = seerrResults,
|
}
|
||||||
rowIndex = SEERR_ROW,
|
}
|
||||||
position = position,
|
|
||||||
focusRequester = focusRequesters[SEERR_ROW],
|
@Composable
|
||||||
onClickItem = { _, _ ->
|
fun SearchCombinedResults(
|
||||||
// no-op
|
result: SearchResult,
|
||||||
},
|
focusRequester: FocusRequester,
|
||||||
onClickDiscover = { _, item ->
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
val dest =
|
onPlayItem: (Int, BaseItem) -> Unit,
|
||||||
if (item.jellyfinItemId != null && item.type.baseItemKind != null) {
|
onClickPosition: (RowColumn) -> Unit,
|
||||||
Destination.MediaItem(
|
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||||
itemId = item.jellyfinItemId,
|
positionCallback: (columns: Int, position: Int) -> Unit,
|
||||||
type = item.type.baseItemKind,
|
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),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Destination.DiscoveredItem(item)
|
SearchGrid(
|
||||||
}
|
items = r.items,
|
||||||
viewModel.navigationManager.navigateTo(dest)
|
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,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onClickPosition = { position = it },
|
modifier = modifier,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <T : CardGridItem> SearchGrid(
|
||||||
|
items: List<T?>,
|
||||||
|
focusRequester: FocusRequester,
|
||||||
|
onClickItem: (Int, T) -> Unit,
|
||||||
|
onPlayItem: (Int, T) -> Unit,
|
||||||
|
onClickPosition: (RowColumn) -> Unit,
|
||||||
|
cardContent: @Composable (GridItemDetails<T>) -> 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)
|
||||||
|
},
|
||||||
|
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 -> {
|
is SearchResult.Success -> {
|
||||||
if (r.items.isEmpty()) {
|
if (r.items.isNotEmpty()) {
|
||||||
SearchResultPlaceholder(
|
|
||||||
title = stringResource(title),
|
|
||||||
message = stringResource(R.string.no_results),
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ItemRow(
|
ItemRow(
|
||||||
title = stringResource(title),
|
title = stringResource(title),
|
||||||
items = r.items,
|
items = r.items,
|
||||||
|
|
@ -600,13 +1019,7 @@ fun LazyListScope.searchResultRow(
|
||||||
}
|
}
|
||||||
|
|
||||||
is SearchResult.SuccessSeerr -> {
|
is SearchResult.SuccessSeerr -> {
|
||||||
if (r.items.isEmpty()) {
|
if (r.items.isNotEmpty()) {
|
||||||
SearchResultPlaceholder(
|
|
||||||
title = stringResource(title),
|
|
||||||
message = stringResource(R.string.no_results),
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ItemRow(
|
ItemRow(
|
||||||
title = stringResource(title),
|
title = stringResource(title),
|
||||||
items = r.items,
|
items = r.items,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -165,6 +165,10 @@ message ScreensaverPreferences{
|
||||||
bool show_clock = 7;
|
bool show_clock = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message SearchPreferences {
|
||||||
|
bool combined_search_results = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message InterfacePreferences {
|
message InterfacePreferences {
|
||||||
ThemeSongVolume play_theme_songs = 1;
|
ThemeSongVolume play_theme_songs = 1;
|
||||||
bool remember_selected_tab = 2;
|
bool remember_selected_tab = 2;
|
||||||
|
|
@ -179,6 +183,7 @@ message InterfacePreferences {
|
||||||
ScreensaverPreferences screensaver_preference = 11;
|
ScreensaverPreferences screensaver_preference = 11;
|
||||||
bool enable_media_management = 12;
|
bool enable_media_management = 12;
|
||||||
bool show_logos = 13;
|
bool show_logos = 13;
|
||||||
|
SearchPreferences search_preferences = 14;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AdvancedPreferences {
|
message AdvancedPreferences {
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,10 @@
|
||||||
<string name="color_code_programs">Color-code programs</string>
|
<string name="color_code_programs">Color-code programs</string>
|
||||||
<string name="subtitle_delay">Subtitle delay</string>
|
<string name="subtitle_delay">Subtitle delay</string>
|
||||||
<string name="backdrop_display">Backdrop style</string>
|
<string name="backdrop_display">Backdrop style</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_off">Show results by category</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>
|
||||||
<string name="play_trailer">Play trailer</string>
|
<string name="play_trailer">Play trailer</string>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue