From f91bca18ffe64e03179c1abeb149fe6371158b0d Mon Sep 17 00:00:00 2001 From: Damontecres Date: Wed, 26 Aug 2026 14:39:02 -0400 Subject: [PATCH] Add more searchable types & choose what to search (#1857) ## Description This PR adds several more data types to search. It also adds a setting (per use profile) to choose which types to include/exclude when searching. The combined results now uses the same types instead of a separate list. A user can only search for types that they have access to. For example, they need Live TV access for TV channels/programs or need access to a movies-type library to search movies. Added types to search: - Photos & Photo albums - Playlists - People - TV channels - Live TV programs Clicking a TV channel will play it. Clicking a TV program shows the program dialog allowing for recording or watching live if it's currently airing. On the main search page you can also toggle whether to search Seerr or not. This does not apply to the discover search tab. Dev note: Some minor refactoring to move setting up/canceling live tv recordings into a new service so it can be shared between with the live tv & search pages. Also search related files are moved into the same package now. ### Related issues Closes #409 Closes #646 Closes #1668 Closes #1750 ### Testing Emulator using users with different permissions and library access ## Screenshots image ## AI or LLM usage None --- .../wholphin/data/model/HomeRowConfig.kt | 2 +- .../wholphin/services/LiveTvService.kt | 94 ++ .../damontecres/wholphin/ui/Formatting.kt | 6 +- .../damontecres/wholphin/ui/UiConstants.kt | 1 + .../wholphin/ui/cards/SeasonCard.kt | 70 +- .../ui/components/CollectionFolderGrid.kt | 10 +- .../wholphin/ui/detail/livetv/DvrSchedule.kt | 11 +- .../ui/detail/livetv/LiveTvViewModel.kt | 75 +- .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 9 +- .../ui/discover/DiscoverSearchPage.kt | 4 +- .../wholphin/ui/main/SearchPage.kt | 1323 ----------------- .../ui/main/settings/HomeSettingsPage.kt | 2 +- .../wholphin/ui/nav/DestinationContent.kt | 2 +- .../ui/{detail => }/search/SearchForDialog.kt | 3 +- .../{detail => }/search/SearchForViewModel.kt | 3 +- .../wholphin/ui/search/SearchPage.kt | 770 ++++++++++ .../wholphin/ui/search/SearchPageCard.kt | 149 ++ .../ui/search/SearchTypeOptionsDialog.kt | 100 ++ .../wholphin/ui/search/SearchViewModel.kt | 628 ++++++++ .../ui/search/SearchViewOptionsDialog.kt | 121 ++ app/src/main/res/values/strings.xml | 1 + 21 files changed, 1968 insertions(+), 1416 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt rename app/src/main/java/com/github/damontecres/wholphin/ui/{detail => }/search/SearchForDialog.kt (98%) rename app/src/main/java/com/github/damontecres/wholphin/ui/{detail => }/search/SearchForViewModel.kt (97%) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 74a6c6d1..231cb670 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -243,7 +243,7 @@ data class HomeRowViewOptions( val liveTvDefault = HomeRowViewOptions( - heightDp = 96, + heightDp = Cards.HEIGHT_LIVE_TV, aspectRatio = AspectRatio.WIDE, contentScale = PrefContentScale.FIT, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt new file mode 100644 index 00000000..d89916f0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LiveTvService.kt @@ -0,0 +1,94 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.toServerString +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.model.api.TimerInfoDto +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Functions for recording live tv programs + */ +@Singleton +class LiveTvService + @Inject + constructor( + private val api: ApiClient, + ) { + /** + * Fetch a tv program by ID + */ + suspend fun fetchProgramForDialog(programId: UUID): BaseItem { + val result = + api.liveTvApi + .getProgram(programId.toServerString()) + .content + .let { BaseItem(it) } + return result + } + + /** + * Cancel a recording by [timerId] + * + * @param series whether the timerId is a series recording or not + * @param timerId the recording ID + */ + suspend fun cancelRecording( + series: Boolean, + timerId: String?, + ): Boolean = + if (timerId != null) { + if (series) { + api.liveTvApi.cancelSeriesTimer(timerId) + } else { + api.liveTvApi.cancelTimer(timerId) + } + true + } else { + false + } + + /** + * Record tv program + * + * @param programId the tv program ID + * @param series whether to set up series recording or just a single recording + */ + suspend fun record( + programId: UUID, + series: Boolean, + ) { + val d by api.liveTvApi.getDefaultTimer(programId.toServerString()) + if (series) { + api.liveTvApi.createSeriesTimer(d) + } else { + val payload = + TimerInfoDto( + id = d.id, + type = d.type, + serverId = d.serverId, + externalId = d.externalId, + channelId = d.channelId, + externalChannelId = d.externalChannelId, + channelName = d.channelName, + programId = d.programId, + externalProgramId = d.externalProgramId, + name = d.name, + overview = d.overview, + startDate = d.startDate, + endDate = d.endDate, + serviceName = d.serviceName, + priority = d.priority, + prePaddingSeconds = d.prePaddingSeconds, + postPaddingSeconds = d.postPaddingSeconds, + isPrePaddingRequired = d.isPrePaddingRequired, + isPostPaddingRequired = d.isPostPaddingRequired, + keepUntil = d.keepUntil, + ) + api.liveTvApi.createTimer(payload) + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index aeb22e95..4b2f0b8b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -220,6 +220,10 @@ fun listToDotString( } } +@get:StringRes +val BaseItemKind.titleStringRes: Int + get() = formatTypeName(this) + @StringRes fun formatTypeName(type: BaseItemKind): Int = when (type) { @@ -239,7 +243,7 @@ fun formatTypeName(type: BaseItemKind): Int = BaseItemKind.MUSIC_GENRE -> R.string.genres BaseItemKind.MUSIC_VIDEO -> R.string.music_videos BaseItemKind.PHOTO -> R.string.photos - BaseItemKind.PHOTO_ALBUM -> R.string.photos + BaseItemKind.PHOTO_ALBUM -> R.string.photo_albums BaseItemKind.PROGRAM -> R.string.programs BaseItemKind.RECORDING -> R.string.recordings BaseItemKind.SEASON -> R.string.tv_seasons diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index a37eb2f5..2e57b407 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -94,6 +94,7 @@ object Cards { const val HEIGHT_2X3_DP = 172 val height2x3 = HEIGHT_2X3_DP.dp const val HEIGHT_EPISODE = 128 + const val HEIGHT_LIVE_TV = 96 val heightEpisode = HEIGHT_EPISODE.dp val playedPercentHeight = 6.dp val serverUserCircle = height2x3 * .75f diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index a6f69071..1992cab4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -51,37 +51,7 @@ fun SeasonCard( showImageOverlay: Boolean = false, aspectRatio: Float = item?.aspectRatio ?: AspectRatios.TALL, ) { - val imageUrlService = LocalImageUrlService.current - val density = LocalDensity.current - val imageUrl = - remember(item, imageHeight, imageWidth, density) { - if (item != null) { - val fillHeight = - if (imageHeight.isSpecified) { - with(density) { - imageHeight.roundToPx() - } - } else { - null - } - val fillWidth = - if (imageWidth.isSpecified) { - with(density) { - imageWidth.roundToPx() - } - } else { - null - } - imageUrlService.getItemImageUrl( - item, - ImageType.PRIMARY, - fillWidth = fillWidth, - fillHeight = fillHeight, - ) - } else { - null - } - } + val imageUrl = rememberImageUrl(item, imageHeight, imageWidth) SeasonCard( title = item?.title, subtitle = item?.subtitle, @@ -103,6 +73,44 @@ fun SeasonCard( ) } +@Composable +fun rememberImageUrl( + item: BaseItem?, + imageHeight: Dp, + imageWidth: Dp = Dp.Unspecified, +): String? { + val density = LocalDensity.current + val imageUrlService = LocalImageUrlService.current + return remember(item, imageHeight, imageWidth, density) { + if (item != null) { + val fillHeight = + if (imageHeight.isSpecified) { + with(density) { + imageHeight.roundToPx() + } + } else { + null + } + val fillWidth = + if (imageWidth.isSpecified) { + with(density) { + imageWidth.roundToPx() + } + } else { + null + } + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + null + } + } +} + private val spaceBetweenFocused = 4.dp private val spaceBetweenUnfocused = 12.dp diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 0493629b..024eeea4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -206,7 +206,7 @@ val CollectionType.baseItemKinds: List } CollectionType.TVSHOWS -> { - listOf(BaseItemKind.SERIES) + listOf(BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE) } CollectionType.HOMEVIDEOS -> { @@ -221,6 +221,10 @@ val CollectionType.baseItemKinds: List ) } + CollectionType.MUSICVIDEOS -> { + listOf(BaseItemKind.MUSIC_VIDEO) + } + CollectionType.BOXSETS -> { listOf(BaseItemKind.BOX_SET) } @@ -229,6 +233,10 @@ val CollectionType.baseItemKinds: List listOf(BaseItemKind.PLAYLIST) } + CollectionType.PHOTOS -> { + listOf(BaseItemKind.PHOTO, BaseItemKind.PHOTO_ALBUM) + } + else -> { listOf() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt index d413f190..5de558c2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.text.format.DateUtils +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -178,6 +179,7 @@ fun DvrSchedule( .focusRequester(focusRequester), ) showDialog?.let { item -> + val context = LocalContext.current ProgramDialog( state = DataLoadingState.Success(item), canRecord = true, @@ -185,13 +187,18 @@ fun DvrSchedule( showDialog = null }, onWatch = { program -> - program.data.channelId?.let { + val channelId = program.data.channelId + if (channelId != null) { viewModel.navigationManager.navigateTo( Destination.Playback( - itemId = it, + itemId = channelId, positionMs = 0L, ), ) + } else { + Toast + .makeText(context, "Program has no channel ID", Toast.LENGTH_LONG) + .show() } }, onRecord = { _, _ -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index a2a4bd01..987aeebb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LiveTvService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.data.RowColumn @@ -25,7 +26,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState @@ -51,7 +52,6 @@ import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder -import org.jellyfin.sdk.model.api.TimerInfoDto import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber @@ -81,6 +81,7 @@ class LiveTvViewModel private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, private val favoriteWatchManager: FavoriteWatchManager, + private val liveTvService: LiveTvService, ) : ViewModel() { private lateinit var channelsIdToIndex: Map private val mutex = Mutex() @@ -430,12 +431,10 @@ class LiveTvViewModel _programDialogState.update { it.copy(loading = DataLoadingState.Loading) } viewModelScope.launchDefault { try { - val result = - api.liveTvApi - .getProgram(programId.toServerString()) - .content - .let { BaseItem(it) } + val result = liveTvService.fetchProgramForDialog(programId) _programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) } + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { Timber.e(ex, "Error fetching program $programId") _programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) } @@ -447,16 +446,19 @@ class LiveTvViewModel series: Boolean, timerId: String?, ) { - if (timerId != null) { - viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - if (series) { - api.liveTvApi.cancelSeriesTimer(timerId) - } else { - api.liveTvApi.cancelTimer(timerId) - } - state.value.let { - refreshPrograms(it.channels, it.programs.range) + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + try { + val result = liveTvService.cancelRecording(series, timerId) + if (result) { + state.value.let { + refreshPrograms(it.channels, it.programs.range) + } } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error canceling timer %s, series=%s", timerId, series) + showToast(context, "Error: ${ex.localizedMessage}") } } } @@ -466,37 +468,16 @@ class LiveTvViewModel series: Boolean, ) { viewModelScope.launchIO { - val d by api.liveTvApi.getDefaultTimer(programId.toServerString()) - if (series) { - api.liveTvApi.createSeriesTimer(d) - } else { - val payload = - TimerInfoDto( - id = d.id, - type = d.type, - serverId = d.serverId, - externalId = d.externalId, - channelId = d.channelId, - externalChannelId = d.externalChannelId, - channelName = d.channelName, - programId = d.programId, - externalProgramId = d.externalProgramId, - name = d.name, - overview = d.overview, - startDate = d.startDate, - endDate = d.endDate, - serviceName = d.serviceName, - priority = d.priority, - prePaddingSeconds = d.prePaddingSeconds, - postPaddingSeconds = d.postPaddingSeconds, - isPrePaddingRequired = d.isPrePaddingRequired, - isPostPaddingRequired = d.isPostPaddingRequired, - keepUntil = d.keepUntil, - ) - api.liveTvApi.createTimer(payload) - } - state.value.let { - refreshPrograms(it.channels, it.programs.range) + try { + liveTvService.record(programId, series) + state.value.let { + refreshPrograms(it.channels, it.programs.range) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error recording %s, series=%s", programId, series) + showToast(context, "Error: ${ex.localizedMessage}") } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index c9b72031..aafaac87 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -211,13 +211,18 @@ fun TvGuideGrid( onDismissRequest = onDismissRequest, onWatch = { onDismissRequest.invoke() - it.data.channelId?.let { + val channelId = it.data.channelId + if (channelId != null) { viewModel.navigationManager.navigateTo( Destination.Playback( - itemId = it, + itemId = channelId, positionMs = 0L, ), ) + } else { + Toast + .makeText(context, "Program has no channel ID", Toast.LENGTH_LONG) + .show() } }, onRecord = { program, series -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt index 135b9324..fccf32ac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverSearchPage.kt @@ -50,10 +50,10 @@ 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.search.SearchCombinedResults +import com.github.damontecres.wholphin.ui.search.SearchResult import com.github.damontecres.wholphin.ui.tryRequestFocus import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException 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 deleted file mode 100644 index 1709e588..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ /dev/null @@ -1,1323 +0,0 @@ -package com.github.damontecres.wholphin.ui.main - -import android.content.Context -import android.view.Gravity -import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandHorizontally -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally -import androidx.compose.animation.shrinkVertically -import androidx.compose.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.mutableIntStateOf -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.graphics.Color -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.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.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.ServerRepository -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.FavoriteWatchManager -import com.github.damontecres.wholphin.services.MediaManagementService -import com.github.damontecres.wholphin.services.MediaReportService -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.services.deleteItem -import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.Cards -import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle -import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard -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.ContextMenuProvider -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton -import com.github.damontecres.wholphin.ui.components.SearchEditTextBox -import com.github.damontecres.wholphin.ui.components.TabDetails -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.components.rememberContextMenu -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.launchDefault -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.showToast -import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler -import com.github.damontecres.wholphin.util.SearchRelevance -import com.github.damontecres.wholphin.util.WholphinDispatchers -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.api.request.GetItemsRequest -import timber.log.Timber -import java.util.UUID -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds - -@HiltViewModel -class SearchViewModel - @Inject - constructor( - @param:ApplicationContext private val context: Context, - val api: ApiClient, - val navigationManager: NavigationManager, - private val appPreferences: DataStore, - private val seerrService: SeerrService, - val voiceInputManager: VoiceInputManager, - val userPreferencesService: UserPreferencesService, - private val serverRepository: ServerRepository, - private val favoriteWatchManager: FavoriteWatchManager, - private val mediaManagementService: MediaManagementService, - private val mediaReportService: MediaReportService, - ) : ViewModel(), - ContextMenuProvider { - val seerrActive = seerrService.active - - private val _state = MutableStateFlow(SearchState()) - val state: StateFlow = _state - val position = MutableStateFlow(RowColumn(0, 0)) - - private var currentQuery: String? = null - private var combinedMode = false - - fun search( - query: String?, - combined: Boolean = false, - ) { - if (currentQuery == query && combinedMode == combined) { - return - } - currentQuery = query - combinedMode = combined - if (query.isNotNullOrBlank()) { - _state.update { SearchState.searchingState } - if (combined) { - searchCombined(query) - } else { - searchInternal( - query, - BaseItemKind.MOVIE, - ) { result, state -> state.copy(movies = result) } - searchInternal( - query, - BaseItemKind.SERIES, - ) { result, state -> state.copy(series = result) } - searchInternal(query, BaseItemKind.EPISODE) { result, state -> - state.copy( - episodes = result, - ) - } - searchInternal(query, BaseItemKind.BOX_SET) { result, state -> - state.copy( - collections = result, - ) - } - searchInternal(query, BaseItemKind.MUSIC_ALBUM) { result, state -> - state.copy( - albums = result, - ) - } - searchInternal(query, BaseItemKind.MUSIC_ARTIST) { result, state -> - state.copy( - artists = result, - ) - } - searchInternal( - query, - BaseItemKind.AUDIO, - ) { result, state -> state.copy(songs = result) } - } - searchSeerr(query) - } else { - _state.update { SearchState() } - } - } - - private fun searchInternal( - query: String, - type: BaseItemKind, - update: (SearchResult, SearchState) -> SearchState, - ) { - viewModelScope.launchIO { - try { - 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.map { - BaseItem(it, false) - } - val sorted = - items.sortedWith( - compareBy { SearchRelevance.score(it, query) } - .thenBy { it.sortName }, - ) - _state.update { update.invoke(SearchResult.Success(sorted), it) } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - Timber.e(ex, "Exception searching for $type") - _state.update { update.invoke(SearchResult.Error(ex), it) } - } - } - } - - private fun searchCombined(query: String) { - viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.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.map { - BaseItem(it, false) - } - val sorted = - items.sortedWith( - compareBy { SearchRelevance.score(it, query) } - .thenBy { it.name ?: "" }, - ) - _state.update { it.copy(combinedResults = SearchResult.Success(sorted)) } - } catch (ex: Exception) { - Timber.e(ex, "Exception in combined search") - _state.update { it.copy(combinedResults = SearchResult.Error(ex)) } - } - } - } - - fun setCombinedResults(enabled: Boolean) { - viewModelScope.launchIO { - appPreferences.updateData { - it.updateSearchPreferences { - combinedSearchResults = enabled - } - } - } - } - - fun setVoiceSearchButtonVisible(visible: Boolean) { - viewModelScope.launchIO { - appPreferences.updateData { - it.updateSearchPreferences { - showVoiceSearchButton = visible - } - } - } - } - - private fun searchSeerr(query: String) { - viewModelScope.launchIO { - if (seerrService.active.first()) { - _state.update { it.copy(seerrResults = SearchResult.Searching) } - val results = - seerrService - .search(query) - .map { seerrService.createDiscoverItem(it) } - .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } - _state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) } - } - } - } - - init { - addCloseable(voiceInputManager) - } - - override fun navigateTo(destination: Destination) { - navigationManager.navigateTo(destination) - } - - override fun canDelete( - item: BaseItem, - appPreferences: AppPreferences, - ): Boolean = mediaManagementService.canDelete(item, appPreferences) - - override fun deleteItem( - index: Int, - item: BaseItem, - ) { - deleteItem(context, mediaManagementService, item) { - viewModelScope.launchDefault { - refreshItem(item.id) - } - } - } - - private suspend fun refreshItem(itemId: UUID) { - try { - val position = position.value - val searchResult = - if (combinedMode) { - state.value.combinedResults - } else { - when (position.row) { - MOVIE_ROW -> state.value.movies - SERIES_ROW -> state.value.series - EPISODE_ROW -> state.value.episodes - COLLECTION_ROW -> state.value.collections - ALBUM_ROW -> state.value.albums - ARTIST_ROW -> state.value.artists - SONG_ROW -> state.value.songs - SEERR_ROW -> null - else -> null - } - } ?: return - val items = (searchResult as? SearchResult.Success)?.items ?: return - - Timber.v("Item refresh: position=%s", position) - val item = items.getOrNull(position.column) - // Exact item deleted (eg a movie) or deleted item was within the series - if (item != null && item.id == itemId) { - val newItem = - api.userLibraryApi - .getItem(item.id) - .content - .let { BaseItem(it) } - val newList = - SearchResult.Success( - items.toMutableList().apply { - set(position.column, newItem) - }, - ) - _state.update { - if (combinedMode) { - it.copy( - combinedResults = newList, - ) - } else { - when (position.row) { - MOVIE_ROW -> it.copy(movies = newList) - SERIES_ROW -> it.copy(series = newList) - EPISODE_ROW -> it.copy(episodes = newList) - COLLECTION_ROW -> it.copy(collections = newList) - ALBUM_ROW -> it.copy(albums = newList) - ARTIST_ROW -> it.copy(artists = newList) - SONG_ROW -> it.copy(songs = newList) - SEERR_ROW -> it - else -> it - } - } - } - } - } catch (ex: Exception) { - Timber.e(ex, "Error refreshing item %s", itemId) - showToast(context, "Error refreshing") - } - } - - override fun setWatched( - position: Int, - itemId: UUID, - played: Boolean, - ) { - viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setWatched(itemId, played) - refreshItem(itemId) - } - } - - override fun setFavorite( - position: Int, - itemId: UUID, - favorite: Boolean, - ) { - viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { - favoriteWatchManager.setFavorite(itemId, favorite) - refreshItem(itemId) - } - } - - override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true - - override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) - } - -sealed interface SearchResult { - data object NoQuery : SearchResult - - data object Searching : SearchResult - - data class Error( - val ex: Exception, - ) : SearchResult - - data class Success( - val items: List, - ) : SearchResult - - data class SuccessSeerr( - val items: List, - ) : SearchResult -} - -data class SearchState( - val movies: SearchResult = SearchResult.NoQuery, - val series: SearchResult = SearchResult.NoQuery, - val episodes: SearchResult = SearchResult.NoQuery, - val collections: SearchResult = SearchResult.NoQuery, - val albums: SearchResult = SearchResult.NoQuery, - val artists: SearchResult = SearchResult.NoQuery, - val songs: SearchResult = SearchResult.NoQuery, - val seerrResults: SearchResult = SearchResult.NoQuery, - val combinedResults: SearchResult = SearchResult.NoQuery, -) { - companion object { - val searchingState = - SearchState( - movies = SearchResult.Searching, - series = SearchResult.Searching, - episodes = SearchResult.Searching, - collections = SearchResult.Searching, - albums = SearchResult.Searching, - artists = SearchResult.Searching, - songs = SearchResult.Searching, - seerrResults = SearchResult.Searching, - combinedResults = SearchResult.Searching, - ) - } -} - -private const val SEARCH_ROW = 0 -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 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 SEERR_ROW = SONG_ROW + 1 - -private const val COMBINED_ROW = TAB_ROW + 1 - -@Composable -fun SearchPage( - initialQuery: String, - userPreferences: UserPreferences, - modifier: Modifier = Modifier, - viewModel: SearchViewModel = hiltViewModel(), -) { - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - val state by viewModel.state.collectAsState() - - // Start with current preferences, but collect updates when view options change - val prefs = - viewModel.userPreferencesService.flow - .collectAsState(userPreferences) - .value.appPreferences.interfacePreferences.searchPreferences - val combinedMode = prefs.combinedSearchResults - val voiceSearchButtonVisible = prefs.showVoiceSearchButton - -// val query = rememberTextFieldState() - var query by rememberSaveable { mutableStateOf(initialQuery) } - val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } - - val seerrActive by viewModel.seerrActive.collectAsState(initial = false) - var selectedTab by rememberSaveable { mutableIntStateOf(0) } - var showViewOptions by rememberSaveable { mutableStateOf(false) } - var searchClicked by rememberSaveable(query) { mutableStateOf(false) } - var immediateSearchQuery by rememberSaveable { mutableStateOf(null) } - - val position by viewModel.position.collectAsState() - - fun setPosition(pos: RowColumn) { - Timber.v("pos=%s", pos) - viewModel.position.value = pos - } - - val contextMenu = rememberContextMenu(userPreferences, viewModel) - - LifecycleResumeEffect(Unit) { - onPauseOrDispose { - viewModel.voiceInputManager.stopListening() - } - } - - fun triggerImmediateSearch(searchQuery: String) { - immediateSearchQuery = searchQuery - searchClicked = true - viewModel.search(searchQuery, combinedMode) - } - - LaunchedEffect(query, combinedMode) { - when { - immediateSearchQuery == query -> { - immediateSearchQuery = null - } - - else -> { - delay(750.milliseconds) - viewModel.search(query, combinedMode) - } - } - } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position.row)?.tryRequestFocus() - } - val onClickItem = { _: Int, item: BaseItem -> - viewModel.navigationManager.navigateTo(item.destination()) - } - val onLongClickItem = { rowIndex: Int, index: Int, item: BaseItem -> - setPosition(RowColumn(rowIndex, index)) - contextMenu.showContextMenu(index, item) - } - val onPlayItem = { _: Int, item: BaseItem -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) - } - - 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 rememberSaveable { mutableStateOf(true) } - val positionCallback = { columns: Int, index: Int -> - showHeader = index < columns - } - val showTabs = seerrActive && query.isNotBlank() && showHeader && combinedMode - val isLibraryTab = selectedTab == 0 - - LaunchedEffect(seerrActive, query) { - if (!seerrActive || query.isBlank()) { - selectedTab = 0 - } - } - - LaunchedEffect( - searchClicked, - state, - combinedMode, - selectedTab, - seerrActive, - ) { - if (!searchClicked || position.row > TAB_ROW) return@LaunchedEffect - - withContext(WholphinDispatchers.IO) { - // Want to focus on the first successful row after all the ones before it are finished searching - val results = - if (isLibraryTab) { - if (combinedMode) { - listOf(state.combinedResults) - } else { - listOf( - state.movies, - state.series, - state.episodes, - state.collections, - state.albums, - state.artists, - state.songs, - ) - } - } else { - listOf(state.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) { - val targetRow = - if (isLibraryTab) { - if (combinedMode) { - COMBINED_ROW - } else { - MOVIE_ROW + firstSuccess - } - } else { - SEERR_ROW - } -// setPosition(RowColumn(targetRow, 0)) - onMain { focusRequesters[targetRow].tryRequestFocus() } - } - } - } - } - - val tabs = - remember { - listOf( - TabDetails(R.string.library), - TabDetails(R.string.discover), - ) - } - - 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(0.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(start = 16.dp, end = 16.dp, top = 16.dp) - .focusGroup() - .focusRestorer(textFieldFocusRequester) - .focusRequester(focusRequesters[SEARCH_ROW]), - ) { - AnimatedVisibility( - visible = voiceSearchButtonVisible, - 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 - .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 }, - modifier = Modifier.padding(start = 12.dp), - ) - } - } - AnimatedVisibility( - visible = showTabs, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - TabRow( - selectedTabIndex = selectedTab, - tabs = tabs, - onClick = { - selectedTab = it - val row = - when (selectedTab) { - 0 -> COMBINED_ROW - else -> SEERR_ROW - } - setPosition(RowColumn(row, 0)) - }, - modifier = - Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp) - .onFocusChanged { - if (it.hasFocus) setPosition(RowColumn(TAB_ROW, 0)) - }, - ) - } - Box( - modifier = Modifier.fillMaxSize(), - ) { - SideEffect { - Timber.v("isLibraryTab=%s, combinedMode=%s", isLibraryTab, combinedMode) - } - when { - isLibraryTab && combinedMode -> { - SearchCombinedResults( - result = state.combinedResults, - focusRequester = focusRequesters[COMBINED_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(COMBINED_ROW, index, item) - }, - onPlayItem = onPlayItem, - onClickPosition = { setPosition(it) }, - onClickDiscover = onClickDiscover, - positionCallback = positionCallback, - modifier = Modifier.fillMaxSize(), - ) - } - - !isLibraryTab && combinedMode -> { - SearchCombinedResults( - result = state.seerrResults, - focusRequester = focusRequesters[SEERR_ROW], - onClickItem = onClickItem, - onLongClickItem = { _, _ -> }, - onPlayItem = onPlayItem, - onClickPosition = { setPosition(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_title, - result = state.movies, - rowIndex = MOVIE_ROW, - position = position, - focusRequester = focusRequesters[MOVIE_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(MOVIE_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.tv_shows_title, - result = state.series, - rowIndex = SERIES_ROW, - position = position, - focusRequester = focusRequesters[SERIES_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(SERIES_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.episodes, - result = state.episodes, - rowIndex = EPISODE_ROW, - position = position, - focusRequester = focusRequesters[EPISODE_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(EPISODE_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - cardContent = @Composable { index, item, mod, onClick, onLongClick -> - BannerCardWithTitle( - title = item?.title, - subtitle = item?.subtitle, - item = item, - onClick = { - setPosition(RowColumn(EPISODE_ROW, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - modifier = mod.padding(horizontal = 8.dp), - cardHeight = Cards.heightEpisode, - ) - }, - ) - searchResultRow( - title = R.string.collections, - result = state.collections, - rowIndex = COLLECTION_ROW, - position = position, - focusRequester = focusRequesters[COLLECTION_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(COLLECTION_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - ) - searchResultRow( - title = R.string.albums, - result = state.albums, - rowIndex = ALBUM_ROW, - position = position, - focusRequester = focusRequesters[ALBUM_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(ALBUM_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - setPosition(RowColumn(ALBUM_ROW, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.artists, - result = state.artists, - rowIndex = ARTIST_ROW, - position = position, - focusRequester = focusRequesters[ARTIST_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(ARTIST_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - setPosition(RowColumn(ARTIST_ROW, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.songs, - result = state.songs, - rowIndex = SONG_ROW, - position = position, - focusRequester = focusRequesters[SONG_ROW], - onClickItem = onClickItem, - onLongClickItem = { index, item -> - onLongClickItem(SONG_ROW, index, item) - }, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - cardContent = { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - setPosition(RowColumn(SONG_ROW, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.heightEpisode, - aspectRatio = AspectRatios.SQUARE, - showImageOverlay = true, - modifier = mod, - ) - }, - ) - searchResultRow( - title = R.string.discover, - result = state.seerrResults, - rowIndex = SEERR_ROW, - position = position, - focusRequester = focusRequesters[SEERR_ROW], - onClickItem = onClickItem, - onLongClickItem = { _, _ -> }, - onClickDiscover = onClickDiscover, - onClickPosition = { setPosition(it) }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } - } - contextMenu.Compose() - } - - if (showViewOptions) { - SearchViewOptionsDialog( - combinedResults = combinedMode, - onCombinedResultsChange = viewModel::setCombinedResults, - voiceSearchButtonVisible = voiceSearchButtonVisible, - onVoiceSearchButtonVisibleChange = viewModel::setVoiceSearchButtonVisible, - onDismissRequest = { showViewOptions = false }, - ) - } -} - -@Composable -fun SearchViewOptionsDialog( - combinedResults: Boolean, - onCombinedResultsChange: (Boolean) -> Unit, - voiceSearchButtonVisible: Boolean, - onVoiceSearchButtonVisibleChange: (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(), - ) - - ListItem( - selected = false, - headlineContent = { - Text(stringResource(R.string.show_voice_search_button)) - }, - supportingContent = { - Text( - if (voiceSearchButtonVisible) { - stringResource(R.string.visible_ui) - } else { - stringResource(R.string.hidden_ui) - }, - ) - }, - trailingContent = { - Switch( - checked = voiceSearchButtonVisible, - onCheckedChange = onVoiceSearchButtonVisibleChange, - colors = SwitchColors(), - ) - }, - onClick = { onVoiceSearchButtonVisibleChange(!voiceSearchButtonVisible) }, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } -} - -@Composable -fun SearchCombinedResults( - result: SearchResult, - focusRequester: FocusRequester, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, - onPlayItem: (Int, BaseItem) -> Unit, - onClickPosition: (RowColumn) -> Unit, - onClickDiscover: (Int, DiscoverItem) -> Unit, - positionCallback: (columns: Int, position: Int) -> Unit, - modifier: Modifier = Modifier, -) { - when (result) { - SearchResult.NoQuery -> {} - - 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 = result.ex.localizedMessage ?: "Error occurred during search", - messageColor = MaterialTheme.colorScheme.error, - modifier = modifier.padding(16.dp), - ) - } - - is SearchResult.Success -> { - if (result.items.isEmpty()) { - SearchResultPlaceholder( - title = stringResource(R.string.results), - message = stringResource(R.string.no_results), - modifier = modifier.padding(16.dp), - ) - } else { - SearchGrid( - items = result.items, - focusRequester = focusRequester, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onPlayItem = onPlayItem, - onClickPosition = onClickPosition, - positionCallback = positionCallback, - cardContent = { details -> - GridCard( - item = details.item, - onClick = details.onClick, - onLongClick = details.onLongClick, - modifier = details.mod, - fillWidth = details.widthPx, - ) - }, - modifier = modifier, - ) - } - } - - is SearchResult.SuccessSeerr -> { - if (result.items.isEmpty()) { - SearchResultPlaceholder( - title = stringResource(R.string.results), - message = stringResource(R.string.no_results), - modifier = modifier.padding(16.dp), - ) - } else { - SearchGrid( - items = result.items, - focusRequester = focusRequester, - onClickItem = onClickDiscover, - onLongClickItem = { _, _ -> }, - 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, - onLongClickItem: (Int, T) -> Unit, - onPlayItem: (Int, T) -> Unit, - onClickPosition: (RowColumn) -> Unit, - cardContent: @Composable (GridItemDetails) -> Unit, - positionCallback: (columns: Int, position: Int) -> Unit, - modifier: Modifier = Modifier, -) { - 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 = 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, - ) - } -} - -fun LazyListScope.searchResultRow( - @StringRes title: Int, - result: SearchResult, - rowIndex: Int, - position: RowColumn, - focusRequester: FocusRequester, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, - onClickPosition: (RowColumn) -> Unit, - modifier: Modifier = Modifier, - onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, - cardContent: @Composable ( - index: Int, - item: BaseItem?, - modifier: Modifier, - onClick: () -> Unit, - onLongClick: () -> Unit, - ) -> Unit = @Composable { index, item, mod, onClick, onLongClick -> - SeasonCard( - item = item, - onClick = { - onClickPosition.invoke(RowColumn(rowIndex, index)) - onClick.invoke() - }, - onLongClick = onLongClick, - imageHeight = Cards.height2x3, - showImageOverlay = true, - modifier = mod, - ) - }, -) { - item { - when (val r = result) { - is SearchResult.Error -> { - SearchResultPlaceholder( - title = stringResource(title), - message = r.ex.localizedMessage ?: "Error occurred during search", - messageColor = MaterialTheme.colorScheme.error, - modifier = Modifier, - ) - } - - SearchResult.NoQuery -> { - // no-op - } - - SearchResult.Searching -> { - SearchResultPlaceholder( - title = stringResource(title), - message = stringResource(R.string.searching), - modifier = modifier, - ) - } - - is SearchResult.Success -> { - if (r.items.isNotEmpty()) { - ItemRow( - title = stringResource(title), - items = r.items, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - modifier = modifier.focusRequester(focusRequester), - cardContent = cardContent, - ) - } - } - - is SearchResult.SuccessSeerr -> { - if (r.items.isNotEmpty()) { - ItemRow( - title = stringResource(title), - items = r.items, - onClickItem = { index, item -> - onClickPosition.invoke(RowColumn(rowIndex, index)) - onClickDiscover?.invoke(index, item) - }, - onLongClickItem = { _, _ -> }, - modifier = modifier.focusRequester(focusRequester), - cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - DiscoverItemCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - showOverlay = true, - modifier = mod, - ) - }, - ) - } - } - } - } -} - -@Composable -fun SearchResultPlaceholder( - title: String, - message: String, - modifier: Modifier = Modifier, - messageColor: Color = MaterialTheme.colorScheme.onBackground, -) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier.padding(bottom = 32.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = message, - style = MaterialTheme.typography.titleMedium, - color = messageColor, - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 582bd69d..e3bea235 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -36,12 +36,12 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.search.SearchForDialog import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import kotlinx.coroutines.Job diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 7677c6aa..f8497a8f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -42,13 +42,13 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.discover.DiscoverRequestGrid import com.github.damontecres.wholphin.ui.main.HomePage -import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage import com.github.damontecres.wholphin.ui.playback.PlayExternalPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.preferences.user.UserProfilePreferencesPage +import com.github.damontecres.wholphin.ui.search.SearchPage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage import org.jellyfin.sdk.model.api.BaseItemKind diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt similarity index 98% rename from app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt index de47d732..17beee6c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForDialog.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.detail.search +package com.github.damontecres.wholphin.ui.search import androidx.activity.compose.BackHandler import androidx.compose.foundation.focusGroup @@ -51,7 +51,6 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.SearchEditTextBox import com.github.damontecres.wholphin.ui.components.VoiceSearchButton -import com.github.damontecres.wholphin.ui.main.SearchResult import kotlinx.coroutines.delay import org.jellyfin.sdk.model.api.BaseItemKind diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt similarity index 97% rename from app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt rename to app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt index 3ab03300..e34697ed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchForViewModel.kt @@ -1,4 +1,4 @@ -package com.github.damontecres.wholphin.ui.detail.search +package com.github.damontecres.wholphin.ui.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -7,7 +7,6 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.VoiceInputManager import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.main.SearchResult import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.GetItemsRequestHandler diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt new file mode 100644 index 00000000..1bde1c4c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPage.kt @@ -0,0 +1,770 @@ +package com.github.damontecres.wholphin.ui.search + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.graphics.Color +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.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +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.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +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.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.TabDetails +import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.components.VoiceSearchButton +import com.github.damontecres.wholphin.ui.components.rememberContextMenu +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.detail.livetv.ProgramDialog +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.onMain +import com.github.damontecres.wholphin.ui.titleStringRes +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.WholphinDispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import kotlin.time.Duration.Companion.milliseconds + +internal const val SEARCH_ROW = 0 +internal const val TAB_ROW = SEARCH_ROW + 1 +internal const val SEERR_ROW = TAB_ROW + 1 +internal const val COMBINED_ROW = SEERR_ROW +internal const val RESULTS_START = SEERR_ROW + 1 + +@Composable +fun SearchPage( + initialQuery: String, + userPreferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SearchViewModel = hiltViewModel(), +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val state by viewModel.state.collectAsState() + val programDialogState by viewModel.programDialogState.collectAsState() + + // Start with current preferences, but collect updates when view options change + val prefs = + viewModel.userPreferencesService.flow + .collectAsState(userPreferences) + .value.appPreferences.interfacePreferences.searchPreferences + val combinedMode = prefs.combinedSearchResults + val voiceSearchButtonVisible = prefs.showVoiceSearchButton + +// val query = rememberTextFieldState() + var query by rememberSaveable { mutableStateOf(initialQuery) } + val focusRequesters = + remember(state.includedSearchableTypes.size) { List(RESULTS_START + state.includedSearchableTypes.size) { FocusRequester() } } + + val seerrActive by viewModel.seerrActive.collectAsState(initial = false) + var selectedTab by rememberSaveable(seerrActive, state.discoverEnabled) { mutableIntStateOf(0) } + var showViewOptions by rememberSaveable { mutableStateOf(false) } + var showFilterTypeDialog by rememberSaveable { mutableStateOf(false) } + var searchClicked by rememberSaveable(query) { mutableStateOf(false) } + var immediateSearchQuery by rememberSaveable { mutableStateOf(null) } + var showProgramDialog by remember { mutableStateOf(false) } + + val position by viewModel.position.collectAsState() + + fun setPosition(pos: RowColumn) { + Timber.v("pos=%s", pos) + viewModel.position.value = pos + } + + val contextMenu = rememberContextMenu(userPreferences, viewModel) + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + viewModel.voiceInputManager.stopListening() + } + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + searchClicked = true + viewModel.search(searchQuery, combinedMode) + } + + LaunchedEffect(query, combinedMode) { + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750.milliseconds) + viewModel.search(query, combinedMode) + } + } + } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position.row)?.tryRequestFocus() + } + val onClickItem = { _: Int, item: BaseItem -> + Timber.v("Clicked %s, type=%s", item.id, item.type) + if (item.type == BaseItemKind.TV_PROGRAM || item.type == BaseItemKind.PROGRAM || item.type == BaseItemKind.LIVE_TV_PROGRAM) { + viewModel.fetchProgramForDialog(item.id) + showProgramDialog = true + } else { + viewModel.navigationManager.navigateTo(item.destination()) + } + } + val onLongClickItem = { rowIndex: Int, index: Int, item: BaseItem -> + setPosition(RowColumn(rowIndex, index)) + contextMenu.showContextMenu(index, item) + } + val onPlayItem = { _: Int, item: BaseItem -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + } + + 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 rememberSaveable { mutableStateOf(true) } + val positionCallback = { columns: Int, index: Int -> + showHeader = index < columns + } + val showTabs = + seerrActive && state.discoverEnabled && query.isNotBlank() && showHeader && combinedMode + val isLibraryTab = selectedTab == 0 + + LaunchedEffect(seerrActive, query) { + if (!seerrActive || query.isBlank()) { + selectedTab = 0 + } + } + + LaunchedEffect( + searchClicked, + state, + combinedMode, + selectedTab, + seerrActive, + ) { + if (!searchClicked || position.row > TAB_ROW) return@LaunchedEffect + + withContext(WholphinDispatchers.IO) { + // Want to focus on the first successful row after all the ones before it are finished searching + val results = + if (isLibraryTab) { + if (combinedMode) { + listOf(state.combinedResults) + } else { + state.includedSearchableTypes.map { state.results[it] } + } + } else { + listOf(state.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) { + val targetRow = + if (isLibraryTab) { + if (combinedMode) { + COMBINED_ROW + } else { + firstSuccess + } + } else { + SEERR_ROW + } +// setPosition(RowColumn(targetRow, 0)) + onMain { focusRequesters.getOrNull(targetRow)?.tryRequestFocus() } + } + } + } + } + + val tabs = + remember(seerrActive, state.discoverEnabled) { + buildList { + add(TabDetails(R.string.library)) + if (seerrActive && state.discoverEnabled) { + add(TabDetails(R.string.discover)) + } + } + } + + 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(0.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .focusGroup() + .focusRestorer(textFieldFocusRequester) + .focusRequester(focusRequesters[SEARCH_ROW]), + ) { + AnimatedVisibility( + visible = voiceSearchButtonVisible, + 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 + .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 }, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + AnimatedVisibility( + visible = showTabs, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + TabRow( + selectedTabIndex = selectedTab, + tabs = tabs, + onClick = { + selectedTab = it + val row = + when (selectedTab) { + 0 -> COMBINED_ROW + else -> SEERR_ROW + } + setPosition(RowColumn(row, 0)) + }, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp) + .onFocusChanged { + if (it.hasFocus) setPosition(RowColumn(TAB_ROW, 0)) + }, + ) + } + Box( + modifier = Modifier.fillMaxSize(), + ) { +// SideEffect { +// Timber.v("isLibraryTab=%s, combinedMode=%s", isLibraryTab, combinedMode) +// } + when { + isLibraryTab && combinedMode -> { + SearchCombinedResults( + result = state.combinedResults, + focusRequester = focusRequesters[COMBINED_ROW], + onClickItem = onClickItem, + onLongClickItem = { index, item -> + onLongClickItem(COMBINED_ROW, index, item) + }, + onPlayItem = onPlayItem, + onClickPosition = { setPosition(it) }, + onClickDiscover = onClickDiscover, + positionCallback = positionCallback, + modifier = Modifier.fillMaxSize(), + ) + } + + !isLibraryTab && combinedMode -> { + SearchCombinedResults( + result = state.seerrResults, + focusRequester = focusRequesters[SEERR_ROW], + onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, + onPlayItem = onPlayItem, + onClickPosition = { setPosition(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(), + ) { + itemsIndexed(state.includedSearchableTypes) { index, type -> + val rowIndex = RESULTS_START + index + val result = state.results.getOrDefault(type, SearchResult.Searching) + SearchRowResult( + title = type.titleStringRes, + result = result, + rowIndex = rowIndex, + position = position, + focusRequester = focusRequesters[rowIndex], + onClickItem = onClickItem, + onLongClickItem = { index, item -> + onLongClickItem(rowIndex, index, item) + }, + onClickPosition = { setPosition(it) }, + modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SearchPageCard( + item = item, + type = type, + onClick = { + setPosition(RowColumn(rowIndex, index)) + onClick.invoke() + }, + onLongClick = onLongClick, + modifier = mod, + ) + }, + ) + } + + if (seerrActive && state.discoverEnabled) { + item { + SearchRowResult( + title = R.string.discover, + result = state.seerrResults, + rowIndex = SEERR_ROW, + position = position, + focusRequester = focusRequesters[SEERR_ROW], + onClickItem = onClickItem, + onLongClickItem = { _, _ -> }, + onClickDiscover = onClickDiscover, + onClickPosition = { setPosition(it) }, + cardContent = { _, _, _, _, _ -> }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + } + } + contextMenu.Compose() + } + + if (showViewOptions) { + SearchViewOptionsDialog( + combinedResults = combinedMode, + onCombinedResultsChange = viewModel::setCombinedResults, + voiceSearchButtonVisible = voiceSearchButtonVisible, + onVoiceSearchButtonVisibleChange = viewModel::setVoiceSearchButtonVisible, + onClickFilterTypes = { showFilterTypeDialog = true }, + onDismissRequest = { showViewOptions = false }, + ) + } + + if (showFilterTypeDialog) { + SearchTypeOptionsDialog( + onDismissRequest = { showFilterTypeDialog = false }, + searchableTypes = state.possibleSearchableTypes, + excludedSearchableTypes = state.excludedSearchableTypes, + discoverAvailable = seerrActive, + discoverEnabled = state.discoverEnabled, + onClick = viewModel::onClickExcludeSearchableType, + onClickDiscover = viewModel::onClickExcludeDiscover, + ) + } + + if (showProgramDialog) { + val context = LocalContext.current + val onDismissRequest = { showProgramDialog = false } + ProgramDialog( + state = programDialogState.loading, + canRecord = true, + onDismissRequest = onDismissRequest, + onWatch = { + onDismissRequest.invoke() + val channelId = it.data.channelId + if (channelId != null) { + viewModel.navigationManager.navigateTo( + Destination.Playback( + itemId = channelId, + positionMs = 0L, + ), + ) + } else { + Toast.makeText(context, "Program has no channel ID", Toast.LENGTH_LONG).show() + } + }, + onRecord = { program, series -> + viewModel.record( + programId = program.id, + series = series, + ) + onDismissRequest.invoke() + }, + onCancelRecord = { program, series -> + viewModel.cancelRecording( + series = series, + timerId = if (series) program.data.seriesTimerId else program.data.timerId, + ) + onDismissRequest.invoke() + }, + ) + } +} + +@Composable +fun SearchCombinedResults( + result: SearchResult, + focusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + onPlayItem: (Int, BaseItem) -> Unit, + onClickPosition: (RowColumn) -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, + positionCallback: (columns: Int, position: Int) -> Unit, + modifier: Modifier = Modifier, +) { + when (result) { + SearchResult.NoQuery -> {} + + 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 = result.ex.localizedMessage ?: "Error occurred during search", + messageColor = MaterialTheme.colorScheme.error, + modifier = modifier.padding(16.dp), + ) + } + + is SearchResult.Success -> { + if (result.items.isEmpty()) { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.no_results), + modifier = modifier.padding(16.dp), + ) + } else { + SearchGrid( + items = result.items, + focusRequester = focusRequester, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onPlayItem = onPlayItem, + onClickPosition = onClickPosition, + positionCallback = positionCallback, + cardContent = { details -> + GridCard( + item = details.item, + onClick = details.onClick, + onLongClick = details.onLongClick, + modifier = details.mod, + fillWidth = details.widthPx, + ) + }, + modifier = modifier, + ) + } + } + + is SearchResult.SuccessSeerr -> { + if (result.items.isEmpty()) { + SearchResultPlaceholder( + title = stringResource(R.string.results), + message = stringResource(R.string.no_results), + modifier = modifier.padding(16.dp), + ) + } else { + SearchGrid( + items = result.items, + focusRequester = focusRequester, + onClickItem = onClickDiscover, + onLongClickItem = { _, _ -> }, + 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, + onLongClickItem: (Int, T) -> Unit, + onPlayItem: (Int, T) -> Unit, + onClickPosition: (RowColumn) -> Unit, + cardContent: @Composable (GridItemDetails) -> Unit, + positionCallback: (columns: Int, position: Int) -> Unit, + modifier: Modifier = Modifier, +) { + 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 = 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, + ) + } +} + +@Composable +fun SearchRowResult( + @StringRes title: Int, + result: SearchResult, + rowIndex: Int, + position: RowColumn, + focusRequester: FocusRequester, + onClickItem: (Int, BaseItem) -> Unit, + onLongClickItem: (Int, BaseItem) -> Unit, + onClickPosition: (RowColumn) -> Unit, + modifier: Modifier = Modifier, + onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, + cardContent: @Composable ( + index: Int, + item: BaseItem?, + modifier: Modifier, + onClick: () -> Unit, + onLongClick: () -> Unit, + ) -> Unit, +) { + when (val r = result) { + is SearchResult.Error -> { + SearchResultPlaceholder( + title = stringResource(title), + message = r.ex.localizedMessage ?: "Error occurred during search", + messageColor = MaterialTheme.colorScheme.error, + modifier = Modifier, + ) + } + + SearchResult.NoQuery -> { + // no-op + } + + SearchResult.Searching -> { + SearchResultPlaceholder( + title = stringResource(title), + message = stringResource(R.string.searching), + modifier = modifier, + ) + } + + is SearchResult.Success -> { + if (r.items.isNotEmpty()) { + ItemRow( + title = stringResource(title), + items = r.items, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + modifier = modifier.focusRequester(focusRequester), + cardContent = cardContent, + ) + } + } + + is SearchResult.SuccessSeerr -> { + if (r.items.isNotEmpty()) { + ItemRow( + title = stringResource(title), + items = r.items, + onClickItem = { index, item -> + onClickPosition.invoke(RowColumn(rowIndex, index)) + onClickDiscover?.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + modifier = modifier.focusRequester(focusRequester), + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + ) + } + } + } +} + +@Composable +fun SearchResultPlaceholder( + title: String, + message: String, + modifier: Modifier = Modifier, + messageColor: Color = MaterialTheme.colorScheme.onBackground, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.padding(bottom = 32.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = message, + style = MaterialTheme.typography.titleMedium, + color = messageColor, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt new file mode 100644 index 00000000..9b82f24c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchPageCard.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin.ui.search + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle +import com.github.damontecres.wholphin.ui.cards.PersonCard +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.cards.personRowCardWidth +import com.github.damontecres.wholphin.ui.cards.rememberImageUrl +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import java.time.OffsetDateTime + +@Composable +fun SearchPageCard( + item: BaseItem?, + type: BaseItemKind, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + when (type) { + BaseItemKind.EPISODE -> { + BannerCardWithTitle( + title = item?.title, + subtitle = item?.subtitle, + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.padding(horizontal = 8.dp), + cardHeight = Cards.heightEpisode, + ) + } + + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + aspectRatio = AspectRatios.SQUARE, + showImageOverlay = true, + modifier = modifier, + ) + } + + BaseItemKind.PERSON -> { + val imageUrlService = LocalImageUrlService.current + val imageUrl = + remember(item) { + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + ) + } + PersonCard( + name = item?.name ?: "", + role = null, + imageUrl = imageUrl, + favorite = item?.favorite ?: false, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.width(personRowCardWidth), + ) + } + + BaseItemKind.TV_CHANNEL -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.HEIGHT_LIVE_TV.dp, + showImageOverlay = true, + aspectRatio = AspectRatios.WIDE, + modifier = modifier, + ) + } + + BaseItemKind.PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.LIVE_TV_PROGRAM, + -> { + val subtitle = + remember(item) { + val startDate = item?.data?.startDate + val endDate = item?.data?.endDate + if (startDate != null && endDate != null) { + DateUtils.formatDateRange( + context, + startDate + .toInstant(OffsetDateTime.now().offset) + .epochSecond * 1000, + endDate + .toInstant(OffsetDateTime.now().offset) + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_WEEKDAY, + ) + } else { + null + } + } + + SeasonCard( + title = item?.title, + subtitle = subtitle, + name = item?.name, + imageUrl = rememberImageUrl(item, Cards.HEIGHT_LIVE_TV.dp, Dp.Unspecified), + isFavorite = item?.data?.userData?.isFavorite ?: false, + isPlayed = item?.data?.userData?.played ?: false, + unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, + playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + imageHeight = Cards.HEIGHT_LIVE_TV.dp, + imageWidth = Dp.Unspecified, + showImageOverlay = true, + aspectRatio = AspectRatios.WIDE, + ) + } + + else -> { + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.height2x3, + showImageOverlay = true, + modifier = modifier, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt new file mode 100644 index 00000000..f8a6eb2e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchTypeOptionsDialog.kt @@ -0,0 +1,100 @@ +package com.github.damontecres.wholphin.ui.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.main.settings.TitleText +import com.github.damontecres.wholphin.ui.preferences.SwitchColors +import com.github.damontecres.wholphin.ui.titleStringRes +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun SearchTypeOptionsDialog( + onDismissRequest: () -> Unit, + searchableTypes: List, + excludedSearchableTypes: List, + discoverAvailable: Boolean, + discoverEnabled: Boolean, + onClick: (BaseItemKind) -> Unit, + onClickDiscover: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + elevation = 3.dp, + ) { + SearchTypeOptionsDialogContent( + searchableTypes = searchableTypes, + excludedSearchableTypes = excludedSearchableTypes, + discoverAvailable = discoverAvailable, + discoverEnabled = discoverEnabled, + onClick = onClick, + onClickDiscover = onClickDiscover, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Composable +fun SearchTypeOptionsDialogContent( + searchableTypes: List, + excludedSearchableTypes: List, + discoverAvailable: Boolean, + discoverEnabled: Boolean, + onClick: (BaseItemKind) -> Unit, + onClickDiscover: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + TitleText(stringResource(R.string.include_types)) + + LazyColumn { + items(searchableTypes) { searchableType -> + val checked = searchableType !in excludedSearchableTypes + ListItem( + enabled = true, + selected = false, + onClick = { onClick.invoke(searchableType) }, + headlineContent = { + Text(stringResource(searchableType.titleStringRes)) + }, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = {}, + colors = SwitchColors(), + ) + }, + ) + } + if (discoverAvailable) { + item { + ListItem( + enabled = true, + selected = false, + onClick = onClickDiscover, + headlineContent = { + Text(stringResource(R.string.discover)) + }, + trailingContent = { + Switch( + checked = discoverEnabled, + onCheckedChange = {}, + colors = SwitchColors(), + ) + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt new file mode 100644 index 00000000..d0c55c05 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewModel.kt @@ -0,0 +1,628 @@ +package com.github.damontecres.wholphin.ui.search + +import android.content.Context +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +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.updateSearchPreferences +import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.KeyValueService +import com.github.damontecres.wholphin.services.LiveTvService +import com.github.damontecres.wholphin.services.MediaManagementService +import com.github.damontecres.wholphin.services.MediaReportService +import com.github.damontecres.wholphin.services.NavDrawerService +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.services.deleteItem +import com.github.damontecres.wholphin.services.tvAccess +import com.github.damontecres.wholphin.ui.ProgramItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.components.ContextMenuProvider +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.baseItemKinds +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.livetv.ProgramDialogState +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.toBaseItems +import com.github.damontecres.wholphin.util.DataLoadingState +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.SearchRelevance +import com.github.damontecres.wholphin.util.WholphinDispatchers +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.artistsApi +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.personsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + val api: ApiClient, + val navigationManager: NavigationManager, + val appPreferences: DataStore, + private val seerrService: SeerrService, + val voiceInputManager: VoiceInputManager, + val userPreferencesService: UserPreferencesService, + private val serverRepository: ServerRepository, + private val favoriteWatchManager: FavoriteWatchManager, + private val mediaManagementService: MediaManagementService, + private val mediaReportService: MediaReportService, + private val liveTvService: LiveTvService, + private val keyValueService: KeyValueService, + private val navDrawerService: NavDrawerService, + ) : ViewModel(), + ContextMenuProvider { + val seerrActive = seerrService.active + + private val _state = MutableStateFlow(SearchState()) + val state: StateFlow = _state + val position = MutableStateFlow(RowColumn(0, 0)) + + private var currentQuery: String? = null + private var combinedMode = false + + private val _programDialogState = MutableStateFlow(ProgramDialogState()) + val programDialogState: StateFlow = _programDialogState + + private var userLibraryTypes: Set = emptySet() + + init { + init() + } + + private fun init() { + viewModelScope.launchDefault { + val tvAccess = serverRepository.currentUserDto?.tvAccess == true + userLibraryTypes = + serverRepository.currentUser + ?.id + ?.let { userId -> + navDrawerService + .getAllUserLibraries(userId, tvAccess) + .flatMap { it.collectionType.baseItemKinds } + .toSet() + }.orEmpty() + + val excludedSearchableTypes = + serverRepository.currentUser?.id?.let { userId -> + try { + keyValueService + .get>( + userId, + EXCLUDED_SEARCHABLE_TYPES_KEY, + emptyList(), + ).firstOrNull() + } catch (ex: Exception) { + Timber.e(ex, "Error occurred fetching excluded search types") + emptyList() + } + } ?: emptyList() + val discoverEnabled = + serverRepository.currentUser?.id?.let { userId -> + try { + keyValueService + .get( + userId, + INCLUDE_DISCOVER_KEY, + true, + ).firstOrNull() + } catch (ex: Exception) { + Timber.e(ex, "Error occurred fetching excluded search types") + true + } + } ?: true + val searchableTypes = determineSearchableTypes(excludedSearchableTypes) + val possibleSearchableTypes = determineSearchableTypes(emptyList()) + _state.update { + it.copy( + discoverEnabled = discoverEnabled, + includedSearchableTypes = searchableTypes, + possibleSearchableTypes = possibleSearchableTypes, + excludedSearchableTypes = excludedSearchableTypes, + results = + SnapshotStateMap().apply { + searchableTypes.forEach { put(it, SearchResult.NoQuery) } + }, + ) + } + } + } + + private fun determineSearchableTypes(excludedSearchableTypes: List): List { + val tvAccess = serverRepository.currentUserDto?.tvAccess == true + val searchableTypes = + allSearchableTypes.filter { + val excluded = excludedSearchableTypes.contains(it) + // Only include if there's a relevant, accessible library + val hasLibrary = userLibraryTypes.contains(it) + when (it) { + // No library type for person + BaseItemKind.PERSON -> !excluded + + // Remove live tv search if user doesn't have access + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_PROGRAM, + BaseItemKind.TV_PROGRAM, + BaseItemKind.PROGRAM, + -> tvAccess && !excluded + + else -> hasLibrary && !excluded + } + } + return searchableTypes + } + + fun search( + query: String?, + combined: Boolean = false, + force: Boolean = false, + ) { + if (currentQuery == query && combinedMode == combined && !force) { + return + } + currentQuery = query + combinedMode = combined + if (query.isNotNullOrBlank()) { + _state.update { + it.copy( + results = + SnapshotStateMap().apply { + it.includedSearchableTypes.forEach { + put(it, SearchResult.Searching) + } + }, + combinedResults = SearchResult.Searching, + ) + } + if (combined) { + searchCombined(query) + } else { + state.value.includedSearchableTypes.forEach { type -> + searchType(query, type) + } + } + searchSeerr(query) + } else { + _state.update { + it.copy( + results = + SnapshotStateMap().apply { + it.includedSearchableTypes.forEach { + put(it, SearchResult.NoQuery) + } + }, + combinedResults = SearchResult.NoQuery, + ) + } + } + } + + private fun searchType( + query: String, + type: BaseItemKind, + ) { + viewModelScope.launchIO { + try { + val items: List = + when (type) { + BaseItemKind.LIVE_TV_PROGRAM -> { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = ProgramItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ) + api.itemsApi.getItems(request).toBaseItems(api, false) + } + + BaseItemKind.MUSIC_ARTIST -> { + api.artistsApi + .getArtists( + searchTerm = query, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ).toBaseItems(api, false) + } + + BaseItemKind.PERSON -> { + api.personsApi + .getPersons( + searchTerm = query, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + ).toBaseItems(api, false) + } + + else -> { + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = listOf(type), + fields = SlimItemFields, + limit = SEARCH_LIMIT, + enableTotalRecordCount = false, + ) + api.itemsApi.getItems(request).toBaseItems(api, false) + } + } + val sorted = + items.sortedWith( + compareBy { SearchRelevance.score(it, query) } + .thenBy { it.sortName }, + ) + Timber.v("Search finished for %s, %s results", type, sorted.size) + _state.value.results[type] = SearchResult.Success(sorted) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception searching for $type") + _state.value.results[type] = SearchResult.Error(ex) + } + } + } + + private fun searchCombined(query: String) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + try { + Timber.v("Starting searchCombined") + val request = + GetItemsRequest( + searchTerm = query, + recursive = true, + includeItemTypes = state.value.includedSearchableTypes, + fields = SlimItemFields, + limit = SEARCH_LIMIT, + ) + + val result = api.itemsApi.getItems(request).content + val items = + result.items.map { + BaseItem(it, false) + } + val sorted = + items.sortedWith( + compareBy { SearchRelevance.score(it, query) } + .thenBy { it.name ?: "" }, + ) + Timber.v("searchCombined complete %s results", sorted.size) + _state.update { it.copy(combinedResults = SearchResult.Success(sorted)) } + } catch (ex: Exception) { + Timber.e(ex, "Exception in combined search") + _state.update { it.copy(combinedResults = SearchResult.Error(ex)) } + } + } + } + + fun setCombinedResults(enabled: Boolean) { + viewModelScope.launchIO { + appPreferences.updateData { + it.updateSearchPreferences { + combinedSearchResults = enabled + } + } + } + } + + fun setVoiceSearchButtonVisible(visible: Boolean) { + viewModelScope.launchIO { + appPreferences.updateData { + it.updateSearchPreferences { + showVoiceSearchButton = visible + } + } + } + } + + private fun searchSeerr(query: String) { + viewModelScope.launchIO { + if (seerrActive.first() && state.value.discoverEnabled) { + _state.update { it.copy(seerrResults = SearchResult.Searching) } + val results = + seerrService + .search(query) + .map { seerrService.createDiscoverItem(it) } + .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } + _state.update { it.copy(seerrResults = SearchResult.SuccessSeerr(results)) } + } + } + } + + init { + addCloseable(voiceInputManager) + } + + override fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + override fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) + + override fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshItem(item.id) + } + } + } + + private suspend fun refreshItem(itemId: UUID) { + try { + val position = position.value + val searchResult = + if (combinedMode) { + state.value.combinedResults + } else { + state.value.includedSearchableTypes.getOrNull(position.row)?.let { + state.value.results[it] + } + } ?: return + val items = (searchResult as? SearchResult.Success)?.items ?: return + + Timber.v("Item refresh: position=%s", position) + val item = items.getOrNull(position.column) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item != null && item.id == itemId) { + val newItem = + api.userLibraryApi + .getItem(item.id) + .content + .let { BaseItem(it) } + val newList = + SearchResult.Success( + items.toMutableList().apply { + set(position.column, newItem) + }, + ) + if (combinedMode) { + _state.update { + it.copy( + combinedResults = newList, + ) + } + } else { + state.value.includedSearchableTypes.getOrNull(position.row)?.let { type -> + state.value.results[type] = newList + } + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing item %s", itemId) + showToast(context, "Error refreshing") + } + } + + override fun setWatched( + position: Int, + itemId: UUID, + played: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setWatched(itemId, played) + refreshItem(itemId) + } + } + + override fun setFavorite( + position: Int, + itemId: UUID, + favorite: Boolean, + ) { + viewModelScope.launch(ExceptionHandler() + WholphinDispatchers.IO) { + favoriteWatchManager.setFavorite(itemId, favorite) + refreshItem(itemId) + } + } + + override fun isAdministrator(): Boolean = serverRepository.currentUserDto?.policy?.isAdministrator == true + + override fun sendReportFor(itemId: UUID) = mediaReportService.sendReportFor(itemId) + + fun fetchProgramForDialog(programId: UUID) { + _programDialogState.update { it.copy(loading = DataLoadingState.Loading) } + viewModelScope.launchDefault { + try { + val result = liveTvService.fetchProgramForDialog(programId) + _programDialogState.update { it.copy(loading = DataLoadingState.Success(result)) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error fetching program $programId") + _programDialogState.update { it.copy(loading = DataLoadingState.Error(ex)) } + } + } + } + + fun cancelRecording( + series: Boolean, + timerId: String?, + ) { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + try { + val result = liveTvService.cancelRecording(series, timerId) + // TODO update program card? + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error canceling timer %s, series=%s", timerId, series) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun record( + programId: UUID, + series: Boolean, + ) { + viewModelScope.launchIO { + try { + liveTvService.record(programId, series) + // TODO update program card? + refreshItem(programId) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Error recording %s, series=%s", programId, series) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + + fun onClickExcludeSearchableType(type: BaseItemKind) { + viewModelScope.launchIO { + try { + val state = state.value + val updateSearch: Boolean + val newExcludes = + state.excludedSearchableTypes.toMutableList().apply { + if (type in this) { + updateSearch = true + remove(type) + } else { + // If combined, need to update, otherwise just hide the newly excluded results + updateSearch = combinedMode + add(type) + } + } + Timber.v("newExcludes=%s", newExcludes) + serverRepository.currentUser?.id?.let { userId -> + keyValueService.save(userId, EXCLUDED_SEARCHABLE_TYPES_KEY, newExcludes) + } + val searchableTypes = determineSearchableTypes(newExcludes) + _state.update { + it.copy( + includedSearchableTypes = searchableTypes, + excludedSearchableTypes = newExcludes, + ) + } + if (updateSearch && currentQuery.isNotNullOrBlank()) { + Timber.d("Need to update search") + search(currentQuery, combinedMode, true) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception toggling %s", type) + showToast(context, "An error occurred: ${ex.localizedMessage}") + } + } + } + + fun onClickExcludeDiscover() { + viewModelScope.launchIO { + try { + val newIncludeDiscover = state.value.discoverEnabled.not() + Timber.v("newIncludeDiscover=%s", newIncludeDiscover) + serverRepository.currentUser?.id?.let { userId -> + keyValueService.save(userId, INCLUDE_DISCOVER_KEY, newIncludeDiscover) + } + _state.update { + it.copy( + discoverEnabled = newIncludeDiscover, + ) + } + if (newIncludeDiscover) { + currentQuery?.takeIf { it.isNotNullOrBlank() }?.let { query -> + searchSeerr(query) + } + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.e(ex, "Exception discover") + showToast(context, "An error occurred: ${ex.localizedMessage}") + } + } + } + + companion object { + private const val EXCLUDED_SEARCHABLE_TYPES_KEY = "excludedSearchableTypes" + private const val INCLUDE_DISCOVER_KEY = "searchIncludeDiscover" + } + } + +sealed interface SearchResult { + data object NoQuery : SearchResult + + data object Searching : SearchResult + + data class Error( + val ex: Exception, + ) : SearchResult + + data class Success( + val items: List, + ) : SearchResult + + data class SuccessSeerr( + val items: List, + ) : SearchResult +} + +data class SearchState( + val results: SnapshotStateMap = SnapshotStateMap(), + val seerrResults: SearchResult = SearchResult.NoQuery, + val combinedResults: SearchResult = SearchResult.NoQuery, + val possibleSearchableTypes: List = emptyList(), + val includedSearchableTypes: List = emptyList(), + val excludedSearchableTypes: List = emptyList(), + val discoverEnabled: Boolean = true, +) + +private val allSearchableTypes = + listOf( + BaseItemKind.MOVIE, + BaseItemKind.SERIES, + BaseItemKind.EPISODE, + BaseItemKind.BOX_SET, + BaseItemKind.PERSON, + BaseItemKind.TV_CHANNEL, + BaseItemKind.LIVE_TV_PROGRAM, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + BaseItemKind.MUSIC_VIDEO, + BaseItemKind.PLAYLIST, + BaseItemKind.VIDEO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + ) + +private const val SEARCH_LIMIT = 50 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt new file mode 100644 index 00000000..cdbd69e1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/search/SearchViewOptionsDialog.kt @@ -0,0 +1,121 @@ +package com.github.damontecres.wholphin.ui.search + +import android.view.Gravity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.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.ui.preferences.SwitchColors + +@Composable +fun SearchViewOptionsDialog( + combinedResults: Boolean, + onCombinedResultsChange: (Boolean) -> Unit, + voiceSearchButtonVisible: Boolean, + onVoiceSearchButtonVisibleChange: (Boolean) -> Unit, + onClickFilterTypes: () -> 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(), + ) + + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.show_voice_search_button)) + }, + supportingContent = { + Text( + if (voiceSearchButtonVisible) { + stringResource(R.string.visible_ui) + } else { + stringResource(R.string.hidden_ui) + }, + ) + }, + trailingContent = { + Switch( + checked = voiceSearchButtonVisible, + onCheckedChange = onVoiceSearchButtonVisibleChange, + colors = SwitchColors(), + ) + }, + onClick = { onVoiceSearchButtonVisibleChange(!voiceSearchButtonVisible) }, + modifier = Modifier.fillMaxWidth(), + ) + + ListItem( + selected = false, + headlineContent = { + Text(stringResource(R.string.include_types)) + }, + onClick = onClickFilterTypes, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 990d28f8..bfb3caac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -913,4 +913,5 @@ Dim screen after delay Dim percentage Dim screen + Photo albums