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