diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvPodcastGridScaffold.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvPodcastGridScaffold.kt index e8f92ce0ca0..5440eac156a 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvPodcastGridScaffold.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvPodcastGridScaffold.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -30,20 +31,23 @@ import kotlinx.coroutines.flow.first @Composable internal fun TvPodcastGridScaffold( - title: String, itemKeys: List, modifier: Modifier = Modifier, + title: String? = null, + horizontalContentPadding: Dp = 32.dp, autoFocusFirstItem: Boolean = false, restoreFocusTrigger: Int = 0, itemContent: @Composable (index: Int, itemModifier: Modifier) -> Unit, ) { Column(modifier = modifier) { - Text( - text = title, - style = MaterialTheme.tvTypography.title3, - color = MaterialTheme.tvColors.textPrimary, - modifier = Modifier.padding(start = 32.dp, top = 8.dp, bottom = 10.dp), - ) + if (title != null) { + Text( + text = title, + style = MaterialTheme.tvTypography.title3, + color = MaterialTheme.tvColors.textPrimary, + modifier = Modifier.padding(start = horizontalContentPadding, top = 8.dp, bottom = 10.dp), + ) + } val gridState = rememberLazyGridState() var lastFocusedKey by rememberSaveable { mutableStateOf(null) } val focusRequesters = remember(itemKeys.size) { List(itemKeys.size) { FocusRequester() } } @@ -72,7 +76,7 @@ internal fun TvPodcastGridScaffold( columns = GridCells.Fixed(GRID_COLUMNS), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(start = 32.dp, top = 16.dp, end = 32.dp, bottom = 32.dp), + contentPadding = PaddingValues(start = horizontalContentPadding, top = 16.dp, end = horizontalContentPadding, bottom = 32.dp), modifier = Modifier .focusRequester(gridFocusRequester) .focusGroup() diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvTabBar.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvTabBar.kt index af0866e6637..d8f55a1ffb4 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvTabBar.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvTabBar.kt @@ -91,7 +91,7 @@ fun TvTabBar( currentTabPosition = currentTabPosition, doesTabRowHaveFocus = doesTabRowHaveFocus, activeColor = MaterialTheme.tvColors.backgroundActive, - inactiveColor = MaterialTheme.tvColors.backgroundActive, + inactiveColor = MaterialTheme.tvColors.backgroundBase, ) } }, @@ -107,7 +107,7 @@ fun TvTabBar( .then(if (index == selectedTabIndex) Modifier.focusRequester(focusRequester) else Modifier), colors = TabDefaults.pillIndicatorTabColors( contentColor = MaterialTheme.tvColors.textPrimary, - selectedContentColor = MaterialTheme.tvColors.textPrimaryActive, + selectedContentColor = MaterialTheme.tvColors.textPrimary, focusedContentColor = MaterialTheme.tvColors.textPrimary, focusedSelectedContentColor = MaterialTheme.tvColors.textPrimaryActive, inactiveContentColor = MaterialTheme.tvColors.textPrimary, diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchEpisodeRow.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchEpisodeRow.kt index 6a33ab522b3..46cf5b3fd52 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchEpisodeRow.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchEpisodeRow.kt @@ -63,10 +63,11 @@ internal fun TvSearchEpisodeRow( } @Composable -private fun TvSearchEpisodeCard( +internal fun TvSearchEpisodeCard( episode: ImprovedSearchResultItem.EpisodeItem, onClick: () -> Unit, modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, ) { var isFocused by remember { mutableStateOf(false) } val textPrimary = if (isFocused) MaterialTheme.tvColors.textPrimaryActive else MaterialTheme.tvColors.textPrimary @@ -79,6 +80,7 @@ private fun TvSearchEpisodeCard( TvTile( onClick = onClick, + onLongClick = onLongClick, scale = CardDefaults.scale(focusedScale = 1.02f), shape = CardDefaults.shape(shape = RoundedCornerShape(12.dp)), colors = CardDefaults.colors( diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchFilters.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchFilters.kt new file mode 100644 index 00000000000..7657ad3cd39 --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchFilters.kt @@ -0,0 +1,130 @@ +package au.com.shiftyjelly.pocketcasts.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.LocalContentColor +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Tab +import androidx.tv.material3.TabDefaults +import androidx.tv.material3.TabRow +import androidx.tv.material3.TabRowDefaults +import androidx.tv.material3.Text +import au.com.shiftyjelly.pocketcasts.theme.TvTheme +import au.com.shiftyjelly.pocketcasts.theme.tvColors +import au.com.shiftyjelly.pocketcasts.theme.tvTypography + +@Composable +internal fun TvSearchFilters( + selected: TvSearchFilter, + onFilterSelect: (TvSearchFilter) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + FilterDivider(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(20.dp)) + TvSearchFilterPills(selected = selected, onFilterSelect = onFilterSelect) + Spacer(modifier = Modifier.width(20.dp)) + FilterDivider(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun TvSearchFilterPills( + selected: TvSearchFilter, + onFilterSelect: (TvSearchFilter) -> Unit, +) { + val selectedIndex = TvSearchFilter.entries.indexOf(selected) + Box( + modifier = Modifier + .background(MaterialTheme.tvColors.backgroundSunken, RoundedCornerShape(percent = 50)) + .padding(3.dp), + ) { + TabRow( + selectedTabIndex = selectedIndex, + modifier = Modifier.focusRestorer(), + containerColor = Color.Transparent, + indicator = @Composable { tabPositions, doesTabRowHaveFocus -> + tabPositions.getOrNull(selectedIndex)?.let { currentTabPosition -> + TabRowDefaults.PillIndicator( + currentTabPosition = currentTabPosition, + doesTabRowHaveFocus = doesTabRowHaveFocus, + activeColor = MaterialTheme.tvColors.backgroundActive, + inactiveColor = MaterialTheme.tvColors.backgroundBase, + ) + } + }, + ) { + TvSearchFilter.entries.forEachIndexed { index, filter -> + Tab( + selected = index == selectedIndex, + onFocus = { onFilterSelect(filter) }, + onClick = { onFilterSelect(filter) }, + modifier = Modifier + .height(44.dp) + .padding(horizontal = 21.dp), + colors = TabDefaults.pillIndicatorTabColors( + contentColor = MaterialTheme.tvColors.textPrimary, + selectedContentColor = MaterialTheme.tvColors.textPrimary, + focusedContentColor = MaterialTheme.tvColors.textPrimary, + focusedSelectedContentColor = MaterialTheme.tvColors.textPrimaryActive, + inactiveContentColor = MaterialTheme.tvColors.textPrimary, + ), + ) { + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(filter.labelRes), + color = LocalContentColor.current, + style = MaterialTheme.tvTypography.caption1, + ) + } + } + } + } + } +} + +@Composable +private fun FilterDivider(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .height(1.dp) + .background(MaterialTheme.tvColors.overlayBorder), + ) +} + +@Preview(device = Devices.TV_1080p) +@Composable +private fun TvSearchFiltersPreview() { + TvTheme { + TvSearchFilters( + selected = TvSearchFilter.Podcasts, + onFilterSelect = {}, + modifier = Modifier + .background(MaterialTheme.tvColors.backgroundSunken) + .padding(48.dp), + ) + } +} diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt index 9aabf956ea8..39b0ec3ef8a 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt @@ -2,6 +2,7 @@ package au.com.shiftyjelly.pocketcasts.search 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 @@ -13,6 +14,9 @@ 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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -22,10 +26,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -41,6 +47,7 @@ import au.com.shiftyjelly.pocketcasts.component.TvEmptyState import au.com.shiftyjelly.pocketcasts.component.TvEpisodeActionContext import au.com.shiftyjelly.pocketcasts.component.TvEpisodeActionsModal import au.com.shiftyjelly.pocketcasts.component.TvEpisodeInfoModal +import au.com.shiftyjelly.pocketcasts.component.TvPodcastGridScaffold import au.com.shiftyjelly.pocketcasts.component.TvPodcastTile import au.com.shiftyjelly.pocketcasts.component.TvPodcastTileDefaults import au.com.shiftyjelly.pocketcasts.component.TvRow @@ -56,9 +63,13 @@ import au.com.shiftyjelly.pocketcasts.repositories.images.PodcastImage import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import au.com.shiftyjelly.pocketcasts.theme.TvTheme import au.com.shiftyjelly.pocketcasts.theme.tvColors +import androidx.compose.foundation.lazy.grid.itemsIndexed as gridItemsIndexed import au.com.shiftyjelly.pocketcasts.localization.R as LR -private val ContentPadding = PaddingValues(horizontal = 48.dp) +private val ContentHorizontalPadding = 48.dp +private val ContentPadding = PaddingValues(horizontal = ContentHorizontalPadding) +private const val TOP_RESULTS_PREVIEW_COUNT = 6 +private const val EPISODE_GRID_COLUMNS = 2 @Composable fun TvSearchScreen( @@ -67,6 +78,7 @@ fun TvSearchScreen( ) { val query by viewModel.query.collectAsStateWithLifecycle() val searchState by viewModel.searchState.collectAsStateWithLifecycle() + val filter by viewModel.filter.collectAsStateWithLifecycle() val categories by viewModel.categories.collectAsStateWithLifecycle() val discoverRows by viewModel.discoverRows.collectAsStateWithLifecycle() val actionsEpisode by viewModel.actionsEpisode.collectAsStateWithLifecycle() @@ -90,9 +102,11 @@ fun TvSearchScreen( TvSearchContent( query = query, searchState = searchState, + filter = filter, categories = categories, discoverRows = discoverRows, onQueryChange = viewModel::onQueryChange, + onFilterSelect = viewModel::onFilterSelected, onOpenPodcast = { openedPodcastUuid = it }, onPlayEpisode = viewModel::playEpisode, onOpenEpisodeActions = viewModel::openEpisodeActions, @@ -139,9 +153,11 @@ fun TvSearchScreen( private fun TvSearchContent( query: String, searchState: TvSearchState, + filter: TvSearchFilter, categories: List, discoverRows: List, onQueryChange: (String) -> Unit, + onFilterSelect: (TvSearchFilter) -> Unit, onOpenPodcast: (String) -> Unit, onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit, onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit, @@ -149,14 +165,11 @@ private fun TvSearchContent( restoreFocusTrigger: Int = 0, ) { val searchFieldFocusRequester = remember { FocusRequester() } - Column( - modifier = modifier - .fillMaxSize() - .focusGroup() - .focusProperties { - onEnter = { runCatching { searchFieldFocusRequester.requestFocus() } } - }, - ) { + LaunchedEffect(Unit) { + withFrameNanos {} + runCatching { searchFieldFocusRequester.requestFocus() } + } + Column(modifier = modifier.fillMaxSize()) { Column(modifier = Modifier.padding(ContentPadding)) { Spacer(modifier = Modifier.height(40.dp)) TvSearchField( @@ -167,6 +180,15 @@ private fun TvSearchContent( Spacer(modifier = Modifier.height(24.dp)) } + if (searchState !is TvSearchState.Idle) { + TvSearchFilters( + selected = filter, + onFilterSelect = onFilterSelect, + modifier = Modifier.padding(ContentPadding), + ) + Spacer(modifier = Modifier.height(24.dp)) + } + Box( modifier = Modifier .fillMaxWidth() @@ -197,6 +219,7 @@ private fun TvSearchContent( is TvSearchState.Results -> TvSearchResults( results = searchState, + filter = filter, onOpenPodcast = onOpenPodcast, onPlayEpisode = onPlayEpisode, onOpenEpisodeActions = onOpenEpisodeActions, @@ -253,13 +276,72 @@ private fun TvSearchDiscover( @Composable private fun TvSearchResults( results: TvSearchState.Results, + filter: TvSearchFilter, + onOpenPodcast: (String) -> Unit, + onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit, + onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit, + restoreFocusTrigger: Int, +) { + when (filter) { + TvSearchFilter.TopResults -> TvSearchTopResults( + podcasts = results.podcasts, + episodes = results.episodes, + onOpenPodcast = onOpenPodcast, + onPlayEpisode = onPlayEpisode, + onOpenEpisodeActions = onOpenEpisodeActions, + restoreFocusTrigger = restoreFocusTrigger, + ) + + TvSearchFilter.Podcasts -> if (results.podcasts.isEmpty()) { + TvSearchMessage( + title = stringResource(LR.string.tv_search_no_results_title), + subtitle = stringResource(LR.string.tv_search_no_results_subtitle), + ) + } else { + TvPodcastGridScaffold( + itemKeys = results.podcasts.map(ImprovedSearchResultItem.PodcastItem::uuid), + modifier = Modifier.fillMaxSize(), + horizontalContentPadding = ContentHorizontalPadding, + restoreFocusTrigger = restoreFocusTrigger, + ) { index, itemModifier -> + val podcast = results.podcasts[index] + TvPodcastTile( + artworkUrl = PodcastImage.getMediumArtworkUrl(podcast.uuid), + podcastTitle = podcast.title, + onClick = { onOpenPodcast(podcast.uuid) }, + imageModifier = Modifier.fillMaxWidth(), + modifier = itemModifier, + ) + } + } + + TvSearchFilter.Episodes -> if (results.episodes.isEmpty()) { + TvSearchMessage( + title = stringResource(LR.string.tv_search_no_results_title), + subtitle = stringResource(LR.string.tv_search_no_results_subtitle), + ) + } else { + TvSearchEpisodeGrid( + episodes = results.episodes, + onPlayEpisode = onPlayEpisode, + onOpenEpisodeActions = onOpenEpisodeActions, + restoreFocusTrigger = restoreFocusTrigger, + ) + } + } +} + +@Composable +private fun TvSearchTopResults( + podcasts: List, + episodes: List, onOpenPodcast: (String) -> Unit, onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit, onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit, restoreFocusTrigger: Int, ) { val restoreFocusRequester = remember { FocusRequester() } - val hasPodcasts = results.podcasts.isNotEmpty() + val hasPodcasts = podcasts.isNotEmpty() var isInitialComposition by remember { mutableStateOf(true) } LaunchedEffect(restoreFocusTrigger) { if (isInitialComposition) { @@ -272,12 +354,12 @@ private fun TvSearchResults( LazyColumn(modifier = Modifier.fillMaxSize()) { if (hasPodcasts) { tvSearchPodcastsRow( - podcasts = results.podcasts, + podcasts = podcasts, onOpenPodcast = onOpenPodcast, focusRequester = restoreFocusRequester, ) } - if (results.episodes.isNotEmpty()) { + if (episodes.isNotEmpty()) { item { Spacer(modifier = Modifier.height(24.dp)) TvSectionTitle( @@ -288,7 +370,7 @@ private fun TvSearchResults( ) } itemsIndexed( - items = results.episodes, + items = episodes.take(TOP_RESULTS_PREVIEW_COUNT), key = { _, episode -> episode.uuid }, ) { index, episode -> TvSearchEpisodeRow( @@ -305,6 +387,65 @@ private fun TvSearchResults( } } +@Composable +private fun TvSearchEpisodeGrid( + episodes: List, + onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit, + onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit, + restoreFocusTrigger: Int, +) { + val gridState = rememberLazyGridState() + val focusRequesters = remember(episodes.size) { List(episodes.size) { FocusRequester() } } + val gridFocusRequester = remember { FocusRequester() } + var lastFocusedKey by rememberSaveable { mutableStateOf(null) } + + var isInitialComposition by remember { mutableStateOf(true) } + LaunchedEffect(restoreFocusTrigger) { + if (isInitialComposition) { + isInitialComposition = false + } else { + runCatching { gridFocusRequester.requestFocus() } + } + } + + LazyVerticalGrid( + state = gridState, + columns = GridCells.Fixed(EPISODE_GRID_COLUMNS), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(start = 48.dp, end = 48.dp, bottom = 40.dp), + modifier = Modifier + .fillMaxSize() + .focusRequester(gridFocusRequester) + .focusGroup() + .focusProperties { + onEnter = { + val visible = gridState.layoutInfo.visibleItemsInfo + val target = episodes.indexOfFirst { it.uuid == lastFocusedKey } + .takeIf { index -> index >= 0 && visible.any { it.index == index } } + ?: visible.firstOrNull()?.index + target?.let { runCatching { focusRequesters.getOrNull(it)?.requestFocus() } } + } + }, + ) { + gridItemsIndexed(episodes, key = { _, episode -> episode.uuid }) { index, episode -> + TvSearchEpisodeCard( + episode = episode, + onClick = { onPlayEpisode(episode) }, + onLongClick = { onOpenEpisodeActions(episode) }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[index]) + .onFocusChanged { focusState -> + if (focusState.hasFocus) { + lastFocusedKey = episode.uuid + } + }, + ) + } + } +} + private fun LazyListScope.tvSearchPodcastsRow( podcasts: List, onOpenPodcast: (String) -> Unit, @@ -352,6 +493,7 @@ private fun TvSearchScreenPreview() { TvSearchContent( query = "", searchState = TvSearchState.Idle, + filter = TvSearchFilter.TopResults, categories = listOf( DiscoverCategory(id = 1, name = "Comedy", icon = "", source = ""), DiscoverCategory(id = 2, name = "True Crime", icon = "", source = ""), @@ -359,6 +501,7 @@ private fun TvSearchScreenPreview() { ), discoverRows = emptyList(), onQueryChange = {}, + onFilterSelect = {}, onOpenPodcast = {}, onPlayEpisode = {}, onOpenEpisodeActions = {}, diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt index 53084a5ba82..48de1e1b0c9 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt @@ -1,5 +1,6 @@ package au.com.shiftyjelly.pocketcasts.search +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.analytics.SourceView @@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.rx2.await import timber.log.Timber +import au.com.shiftyjelly.pocketcasts.localization.R as LR @HiltViewModel class TvSearchViewModel @Inject constructor( @@ -54,6 +56,9 @@ class TvSearchViewModel @Inject constructor( private val _searchState = MutableStateFlow(TvSearchState.Idle) val searchState: StateFlow = _searchState.asStateFlow() + private val _filter = MutableStateFlow(TvSearchFilter.TopResults) + val filter: StateFlow = _filter.asStateFlow() + private val _playStarted = MutableSharedFlow(extraBufferCapacity = 1) val playStarted: SharedFlow = _playStarted.asSharedFlow() @@ -96,6 +101,7 @@ class TvSearchViewModel @Inject constructor( searchJob?.cancel() val term = query.trim() if (term.isEmpty()) { + _filter.value = TvSearchFilter.TopResults _searchState.value = TvSearchState.Idle return } @@ -123,6 +129,10 @@ class TvSearchViewModel @Inject constructor( } } + fun onFilterSelected(filter: TvSearchFilter) { + _filter.value = filter + } + fun playEpisode(episode: ImprovedSearchResultItem.EpisodeItem) { viewModelScope.launch { try { @@ -183,6 +193,14 @@ private fun Podcast.toSearchItem() = ImprovedSearchResultItem.PodcastItem( isExplicit = explicit == true, ) +enum class TvSearchFilter( + @StringRes val labelRes: Int, +) { + TopResults(LR.string.search_filters_top_results), + Podcasts(LR.string.search_filters_podcasts), + Episodes(LR.string.search_filters_episodes), +} + sealed interface TvSearchState { data object Idle : TvSearchState data object Searching : TvSearchState diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt index 8b7e08af41b..22639aeeefb 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt @@ -307,6 +307,28 @@ class TvSearchViewModelTest { assertEquals(TvSearchState.Error, viewModel.searchState.value) } + @Test + fun `onFilterSelected updates the filter`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + val viewModel = createViewModel() + + viewModel.onFilterSelected(TvSearchFilter.Episodes) + + assertEquals(TvSearchFilter.Episodes, viewModel.filter.value) + } + + @Test + fun `clearing the query resets the filter to top results`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + val viewModel = createViewModel() + + viewModel.onFilterSelected(TvSearchFilter.Podcasts) + viewModel.onQueryChange("") + advanceUntilIdle() + + assertEquals(TvSearchFilter.TopResults, viewModel.filter.value) + } + private fun podcastItem(uuid: String) = ImprovedSearchResultItem.PodcastItem( uuid = uuid, title = "Podcast $uuid",