diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index b8c9fbd35e5..7fa1f41ab94 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -260,8 +260,12 @@ You haven\'t seen the half of it Your next obsession is in here somewhere Discover more shows + Podcasts, shows, authors + Recent searches + Featured Searching… No results + No results for \“%1$s\” Try more general or different keywords. Check your connection and try again. Starred Episodes diff --git a/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/to/ImprovedSearchResultItem.kt b/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/to/ImprovedSearchResultItem.kt index 8198ebd6175..abeb62ba5ec 100644 --- a/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/to/ImprovedSearchResultItem.kt +++ b/modules/services/model/src/main/java/au/com/shiftyjelly/pocketcasts/models/to/ImprovedSearchResultItem.kt @@ -32,5 +32,6 @@ sealed interface ImprovedSearchResultItem { val podcastTitle: String, val publishedDate: Date, val duration: Duration, + val hasVideo: Boolean = false, ) : ImprovedSearchResultItem } diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/search/ImprovedSearchManagerImpl.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/search/ImprovedSearchManagerImpl.kt index 1d3b2da4c5c..4c025aa1f64 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/search/ImprovedSearchManagerImpl.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/search/ImprovedSearchManagerImpl.kt @@ -54,6 +54,7 @@ class ImprovedSearchManagerImpl @Inject constructor( podcastTitle = it.podcastTitle, publishedDate = it.publishedDate, duration = it.duration.seconds, + hasVideo = it.hasVideo, ) CombinedResult.Unknown -> null diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/search/CombinedSearchResponse.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/search/CombinedSearchResponse.kt index 580607e5c06..238a018aead 100644 --- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/search/CombinedSearchResponse.kt +++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/search/CombinedSearchResponse.kt @@ -34,6 +34,8 @@ sealed interface CombinedResult { val podcastTitle: String, @Json(name = "podcast_slug") val podcastSlug: String = "", + @Json(name = "has_video") + val hasVideo: Boolean = false, ) : CombinedResult data object Unknown : CombinedResult 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 46cf5b3fd52..8bb19c5e6b6 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 @@ -41,27 +41,6 @@ import au.com.shiftyjelly.pocketcasts.theme.tvTypography import java.util.Date import kotlin.time.Duration.Companion.seconds -@Composable -internal fun TvSearchEpisodeRow( - episode: ImprovedSearchResultItem.EpisodeItem, - onClick: () -> Unit, - onOpenActions: () -> Unit, - modifier: Modifier = Modifier, - episodeFocusRequester: FocusRequester? = null, -) { - TvEpisodeListItemContainer( - onOpenActions = onOpenActions, - modifier = modifier, - ) { rowModifier -> - TvSearchEpisodeCard( - episode = episode, - onClick = onClick, - modifier = rowModifier - .then(if (episodeFocusRequester != null) Modifier.focusRequester(episodeFocusRequester) else Modifier), - ) - } -} - @Composable internal fun TvSearchEpisodeCard( episode: ImprovedSearchResultItem.EpisodeItem, @@ -139,10 +118,10 @@ internal fun TvSearchEpisodeCard( @Preview(device = Devices.TV_1080p) @Composable -private fun TvSearchEpisodeRowPreview() { +private fun TvSearchEpisodeCardPreview() { TvTheme { Box(modifier = Modifier.background(MaterialTheme.tvColors.backgroundSunken).padding(48.dp)) { - TvSearchEpisodeRow( + TvSearchEpisodeCard( episode = ImprovedSearchResultItem.EpisodeItem( uuid = "episode-1", title = "The real cost of sugar and how it shapes the food we eat", @@ -152,7 +131,6 @@ private fun TvSearchEpisodeRowPreview() { duration = 1440.seconds, ), onClick = {}, - onOpenActions = {}, ) } } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt index b304e0bb7ca..441ca99e44e 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,6 +58,7 @@ internal fun TvSearchField( query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier, + onEditingChange: (Boolean) -> Unit = {}, ) { var editing by remember { mutableStateOf(false) } var restoreRestFocus by remember { mutableStateOf(false) } @@ -64,8 +66,10 @@ internal fun TvSearchField( val restFocusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current + val currentOnEditingChange by rememberUpdatedState(onEditingChange) LaunchedEffect(editing) { + currentOnEditingChange(editing) if (editing) { runCatching { fieldFocusRequester.requestFocus() } keyboardController?.show() @@ -175,7 +179,7 @@ private fun TvSearchFieldContent( Box(contentAlignment = Alignment.CenterStart) { if (query.isEmpty()) { Text( - text = stringResource(LR.string.search), + text = stringResource(LR.string.tv_search_prompt), style = MaterialTheme.tvTypography.title3, color = MaterialTheme.tvColors.textSecondary, ) 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 b9fb2a147c5..a00d454c203 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 @@ -11,13 +11,15 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow 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.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -27,6 +29,7 @@ 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties @@ -39,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import au.com.shiftyjelly.pocketcasts.component.LocalOpenNowPlaying import au.com.shiftyjelly.pocketcasts.component.LocalTvToastHostState import au.com.shiftyjelly.pocketcasts.component.TvCategoryTile @@ -51,7 +55,7 @@ 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 -import au.com.shiftyjelly.pocketcasts.component.TvSectionTitle +import au.com.shiftyjelly.pocketcasts.component.TvTile import au.com.shiftyjelly.pocketcasts.component.tvFocusInactiveWhen import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView import au.com.shiftyjelly.pocketcasts.discover.TvCategoryPodcastsScreen @@ -67,12 +71,14 @@ import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import au.com.shiftyjelly.pocketcasts.theme.TvTheme import au.com.shiftyjelly.pocketcasts.theme.TvTopBarHeight import au.com.shiftyjelly.pocketcasts.theme.tvColors +import au.com.shiftyjelly.pocketcasts.theme.tvTypography import androidx.compose.foundation.lazy.grid.itemsIndexed as gridItemsIndexed import au.com.shiftyjelly.pocketcasts.localization.R as LR private val ContentHorizontalPadding = 48.dp private val ContentPadding = PaddingValues(horizontal = ContentHorizontalPadding) -private const val TOP_RESULTS_PREVIEW_COUNT = 6 +private const val SEARCH_ROW_LIMIT = 10 +private val SearchEpisodeCardWidth = 360.dp private const val EPISODE_GRID_COLUMNS = 2 @Composable @@ -85,6 +91,8 @@ fun TvSearchScreen( val filter by viewModel.filter.collectAsStateWithLifecycle() val categories by viewModel.categories.collectAsStateWithLifecycle() val discoverRows by viewModel.discoverRows.collectAsStateWithLifecycle() + val suggestions by viewModel.suggestions.collectAsStateWithLifecycle() + val history by viewModel.history.collectAsStateWithLifecycle() val actionsEpisode by viewModel.actionsEpisode.collectAsStateWithLifecycle() var openedPodcastUuid by rememberSaveable { mutableStateOf(null) } @@ -118,6 +126,14 @@ fun TvSearchScreen( onOpenCategory = { openedCategory = TvOpenedCategory(it.id, it.name, it.source) }, onPlayEpisode = viewModel::playEpisode, onOpenEpisodeActions = viewModel::openEpisodeActions, + history = history, + onHistorySelect = viewModel::onQueryChange, + suggestions = suggestions, + onSuggestionSelect = { term -> + viewModel.saveSearchTerm(term) + viewModel.onQueryChange(term) + }, + onSaveSearch = viewModel::saveSearchTerm, restoreFocusTrigger = restoreFocusTrigger, modifier = Modifier .fillMaxSize() @@ -187,9 +203,17 @@ private fun TvSearchContent( onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit, onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit, modifier: Modifier = Modifier, + history: List = emptyList(), + onHistorySelect: (String) -> Unit = {}, + suggestions: List = emptyList(), + onSuggestionSelect: (String) -> Unit = {}, + onSaveSearch: (String) -> Unit = {}, restoreFocusTrigger: Int = 0, ) { val searchFieldFocusRequester = remember { FocusRequester() } + var isEditing by remember { mutableStateOf(false) } + var suggestionsFocused by remember { mutableStateOf(false) } + val showSuggestions = (isEditing || suggestionsFocused) && suggestions.isNotEmpty() LaunchedEffect(Unit) { withFrameNanos {} runCatching { searchFieldFocusRequester.requestFocus() } @@ -200,11 +224,26 @@ private fun TvSearchContent( TvSearchField( query = query, onQueryChange = onQueryChange, + onEditingChange = { editing -> + if (!editing && isEditing && query.isNotBlank()) { + onSaveSearch(query) + } + isEditing = editing + }, modifier = Modifier.focusRequester(searchFieldFocusRequester), ) Spacer(modifier = Modifier.height(24.dp)) } + if (showSuggestions) { + TvSearchSuggestions( + suggestions = suggestions, + onSuggestionSelect = onSuggestionSelect, + modifier = Modifier.onFocusChanged { suggestionsFocused = it.hasFocus }, + ) + Spacer(modifier = Modifier.height(24.dp)) + } + if (searchState !is TvSearchState.Idle) { TvSearchFilters( selected = filter, @@ -220,18 +259,17 @@ private fun TvSearchContent( .weight(1f), ) { when (searchState) { - is TvSearchState.Idle -> TvSearchDiscover( + is TvSearchState.Idle -> TvSearchIdle( + history = history, categories = categories, discoverRows = discoverRows, + onHistorySelect = onHistorySelect, onOpenPodcast = onOpenPodcast, onOpenCategory = onOpenCategory, restoreFocusTrigger = restoreFocusTrigger, ) - is TvSearchState.Searching -> LoadingView( - color = MaterialTheme.tvColors.textPrimary, - modifier = Modifier.fillMaxSize(), - ) + is TvSearchState.Searching -> TvSearchLoading() is TvSearchState.Error -> TvSearchMessage( title = stringResource(LR.string.error_generic_message), @@ -241,13 +279,14 @@ private fun TvSearchContent( ) is TvSearchState.NoResults -> TvSearchMessage( - title = stringResource(LR.string.tv_search_no_results_title), + title = stringResource(LR.string.tv_search_no_results_for_title, query.trim()), subtitle = stringResource(LR.string.tv_search_no_results_subtitle), ) is TvSearchState.Results -> TvSearchResults( results = searchState, filter = filter, + searchTerm = query.trim(), onOpenPodcast = onOpenPodcast, onPlayEpisode = onPlayEpisode, onOpenEpisodeActions = onOpenEpisodeActions, @@ -258,6 +297,46 @@ private fun TvSearchContent( } } +@Composable +private fun TvSearchIdle( + history: List, + categories: List, + discoverRows: List, + onHistorySelect: (String) -> Unit, + onOpenPodcast: (String) -> Unit, + onOpenCategory: (DiscoverCategory) -> Unit, + restoreFocusTrigger: Int, +) { + Column(modifier = Modifier.fillMaxSize()) { + if (history.isNotEmpty()) { + TvRow( + title = stringResource(LR.string.tv_search_recent), + items = history, + contentPadding = ContentPadding, + key = { it }, + ) { term -> + TvTile(onClick = { onHistorySelect(term) }) { + Text( + text = term, + style = MaterialTheme.tvTypography.body, + color = MaterialTheme.tvColors.textPrimary, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + } + TvSearchDiscover( + categories = categories, + discoverRows = discoverRows, + onOpenPodcast = onOpenPodcast, + onOpenCategory = onOpenCategory, + restoreFocusTrigger = restoreFocusTrigger, + modifier = Modifier.weight(1f), + ) + } +} + @Composable private fun TvSearchDiscover( categories: List, @@ -265,6 +344,7 @@ private fun TvSearchDiscover( onOpenPodcast: (String) -> Unit, onOpenCategory: (DiscoverCategory) -> Unit, restoreFocusTrigger: Int, + modifier: Modifier = Modifier, ) { val categoryOffset = if (categories.isNotEmpty()) 1 else 0 val rowCount = categoryOffset + discoverRows.size @@ -280,7 +360,7 @@ private fun TvSearchDiscover( } } - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn(modifier = modifier.fillMaxSize()) { if (categories.isNotEmpty()) { item { Box( @@ -328,6 +408,7 @@ private fun TvSearchDiscover( private fun TvSearchResults( results: TvSearchState.Results, filter: TvSearchFilter, + searchTerm: String, onOpenPodcast: (String) -> Unit, onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit, onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit, @@ -344,10 +425,14 @@ private fun TvSearchResults( ) 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), - ) + if (results.isPartial) { + TvSearchLoading() + } else { + TvSearchMessage( + title = stringResource(LR.string.tv_search_no_results_for_title, searchTerm), + subtitle = stringResource(LR.string.tv_search_no_results_subtitle), + ) + } } else { TvPodcastGridScaffold( itemKeys = results.podcasts.map(ImprovedSearchResultItem.PodcastItem::uuid), @@ -367,10 +452,14 @@ private fun TvSearchResults( } 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), - ) + if (results.isPartial) { + TvSearchLoading() + } else { + TvSearchMessage( + title = stringResource(LR.string.tv_search_no_results_for_title, searchTerm), + subtitle = stringResource(LR.string.tv_search_no_results_subtitle), + ) + } } else { TvSearchEpisodeGrid( episodes = results.episodes, @@ -392,7 +481,6 @@ private fun TvSearchTopResults( restoreFocusTrigger: Int, ) { val restoreFocusRequester = remember { FocusRequester() } - val hasPodcasts = podcasts.isNotEmpty() var isInitialComposition by remember { mutableStateOf(true) } LaunchedEffect(restoreFocusTrigger) { if (isInitialComposition) { @@ -402,42 +490,74 @@ private fun TvSearchTopResults( } } + val featured = episodes.filter { it.hasVideo }.take(SEARCH_ROW_LIMIT) + val otherEpisodes = episodes.filterNot { it.hasVideo }.take(SEARCH_ROW_LIMIT) + val topPodcasts = podcasts.take(SEARCH_ROW_LIMIT) + val featuredFirst = featured.isNotEmpty() + val episodesFirst = !featuredFirst && otherEpisodes.isNotEmpty() + val podcastsFirst = !featuredFirst && !episodesFirst + LazyColumn(modifier = Modifier.fillMaxSize()) { - if (hasPodcasts) { - tvSearchPodcastsRow( - podcasts = podcasts, - onOpenPodcast = onOpenPodcast, - focusRequester = restoreFocusRequester, - ) - } - if (episodes.isNotEmpty()) { + item { Spacer(modifier = Modifier.height(8.dp)) } + if (featured.isNotEmpty()) { item { - Spacer(modifier = Modifier.height(24.dp)) - TvSectionTitle( - title = stringResource(LR.string.episodes), - modifier = Modifier - .padding(ContentPadding) - .padding(bottom = 17.dp), + TvSearchEpisodeCarousel( + title = stringResource(LR.string.tv_search_featured), + episodes = featured, + onPlayEpisode = onPlayEpisode, + onOpenEpisodeActions = onOpenEpisodeActions, + focusRequester = restoreFocusRequester.takeIf { featuredFirst }, ) } - itemsIndexed( - items = episodes.take(TOP_RESULTS_PREVIEW_COUNT), - key = { _, episode -> episode.uuid }, - ) { index, episode -> - TvSearchEpisodeRow( - episode = episode, - onClick = { onPlayEpisode(episode) }, - onOpenActions = { onOpenEpisodeActions(episode) }, - modifier = Modifier.padding(ContentPadding), - episodeFocusRequester = if (!hasPodcasts && index == 0) restoreFocusRequester else null, + } + if (otherEpisodes.isNotEmpty()) { + item { Spacer(modifier = Modifier.height(24.dp)) } + item { + TvSearchEpisodeCarousel( + title = stringResource(LR.string.episodes), + episodes = otherEpisodes, + onPlayEpisode = onPlayEpisode, + onOpenEpisodeActions = onOpenEpisodeActions, + focusRequester = restoreFocusRequester.takeIf { episodesFirst }, ) - Spacer(modifier = Modifier.height(12.dp)) } } + if (topPodcasts.isNotEmpty()) { + item { Spacer(modifier = Modifier.height(24.dp)) } + tvSearchPodcastsRow( + podcasts = topPodcasts, + onOpenPodcast = onOpenPodcast, + focusRequester = restoreFocusRequester.takeIf { podcastsFirst }, + ) + } item { Spacer(modifier = Modifier.height(40.dp)) } } } +@Composable +private fun TvSearchEpisodeCarousel( + title: String, + episodes: List, + onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit, + onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit, + focusRequester: FocusRequester?, +) { + TvRow( + title = title, + items = episodes, + contentPadding = ContentPadding, + key = ImprovedSearchResultItem.EpisodeItem::uuid, + focusRequester = focusRequester, + ) { episode -> + TvSearchEpisodeCard( + episode = episode, + onClick = { onPlayEpisode(episode) }, + onLongClick = { onOpenEpisodeActions(episode) }, + modifier = Modifier.width(SearchEpisodeCardWidth), + ) + } +} + @Composable private fun TvSearchEpisodeGrid( episodes: List, @@ -500,7 +620,7 @@ private fun TvSearchEpisodeGrid( private fun LazyListScope.tvSearchPodcastsRow( podcasts: List, onOpenPodcast: (String) -> Unit, - focusRequester: FocusRequester, + focusRequester: FocusRequester?, ) { item { TvRow( @@ -536,6 +656,50 @@ private fun TvSearchMessage( ) } +@Composable +private fun TvSearchSuggestions( + suggestions: List, + onSuggestionSelect: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier = modifier.fillMaxWidth(), + contentPadding = ContentPadding, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(suggestions) { term -> + TvTile(onClick = { onSuggestionSelect(term) }) { + Text( + text = term, + style = MaterialTheme.tvTypography.body, + color = MaterialTheme.tvColors.textPrimary, + maxLines = 1, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + } + } + } +} + +@Composable +private fun TvSearchLoading() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + LoadingView( + color = MaterialTheme.tvColors.textPrimary, + modifier = Modifier.size(48.dp), + ) + Text( + text = stringResource(LR.string.tv_search_searching), + style = MaterialTheme.tvTypography.body, + color = MaterialTheme.tvColors.textSecondary, + ) + } +} + @Preview(device = Devices.TV_1080p) @Composable private fun TvSearchScreenPreview() { 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 370693788da..a8767f1e9b6 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 @@ -10,17 +10,20 @@ import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.models.to.ImprovedSearchResultItem -import au.com.shiftyjelly.pocketcasts.preferences.Settings +import au.com.shiftyjelly.pocketcasts.models.to.SearchAutoCompleteItem +import au.com.shiftyjelly.pocketcasts.models.to.SearchHistoryEntry import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.search.ImprovedSearchManager +import au.com.shiftyjelly.pocketcasts.repositories.searchhistory.SearchHistoryManager import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -42,7 +45,7 @@ class TvSearchViewModel @Inject constructor( private val podcastManager: PodcastManager, private val episodeManager: EpisodeManager, private val playbackManager: PlaybackManager, - private val settings: Settings, + private val searchHistoryManager: SearchHistoryManager, ) : ViewModel() { private val _categories = MutableStateFlow>(emptyList()) @@ -60,6 +63,12 @@ class TvSearchViewModel @Inject constructor( private val _filter = MutableStateFlow(TvSearchFilter.TopResults) val filter: StateFlow = _filter.asStateFlow() + private val _suggestions = MutableStateFlow>(emptyList()) + val suggestions: StateFlow> = _suggestions.asStateFlow() + + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _history.asStateFlow() + private val _playStarted = MutableSharedFlow(extraBufferCapacity = 1) val playStarted: SharedFlow = _playStarted.asSharedFlow() @@ -95,6 +104,15 @@ class TvSearchViewModel @Inject constructor( } } } + viewModelScope.launch { + try { + refreshHistory() + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load TV search history") + } + } } fun onQueryChange(query: String) { @@ -102,18 +120,37 @@ class TvSearchViewModel @Inject constructor( searchJob?.cancel() val term = query.trim() if (term.isEmpty()) { - _filter.value = TvSearchFilter.TopResults + _suggestions.value = emptyList() _searchState.value = TvSearchState.Idle return } searchJob = viewModelScope.launch { - delay(settings.getPodcastSearchDebounceMs()) + delay(SEARCH_DEBOUNCE_MS) _searchState.value = TvSearchState.Searching _searchState.value = try { + val fullSearch = async { runCatching { improvedSearchManager.combinedSearch(term) } } val localPodcasts = podcastManager.findSubscribedFlow(term).first().map(Podcast::toSearchItem) - val remoteResults = improvedSearchManager.combinedSearch(term) - val podcasts = (localPodcasts + remoteResults.filterIsInstance()) + val localUuids = localPodcasts.mapTo(HashSet(), ImprovedSearchResultItem.PodcastItem::uuid) + val predictiveResults = try { + improvedSearchManager.autoCompleteSearch(term) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load TV search suggestions") + emptyList() + } + _suggestions.value = predictiveResults.filterIsInstance().map { it.term } + val predictivePodcasts = predictiveResults.filterIsInstance().map { it.toSearchItem() } + val earlyPodcasts = (predictivePodcasts + localPodcasts).distinctBy(ImprovedSearchResultItem.PodcastItem::uuid) + if (earlyPodcasts.isNotEmpty()) { + _searchState.value = TvSearchState.Results(podcasts = earlyPodcasts, episodes = emptyList(), isPartial = true) + } + + val remoteResults = fullSearch.await().getOrThrow() + val remotePodcasts = remoteResults.filterIsInstance() + val podcasts = (predictivePodcasts + remotePodcasts + localPodcasts) .distinctBy(ImprovedSearchResultItem.PodcastItem::uuid) + .map { if (it.uuid in localUuids) it.copy(isFollowed = true) else it } val episodes = remoteResults.filterIsInstance() .distinctBy(ImprovedSearchResultItem.EpisodeItem::uuid) if (podcasts.isEmpty() && episodes.isEmpty()) { @@ -134,6 +171,29 @@ class TvSearchViewModel @Inject constructor( _filter.value = filter } + fun saveSearchTerm(term: String) { + val trimmed = term.trim() + if (trimmed.isEmpty()) { + return + } + viewModelScope.launch { + try { + searchHistoryManager.add(SearchHistoryEntry.SearchTerm(term = trimmed)) + refreshHistory() + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to save TV search history") + } + } + } + + private suspend fun refreshHistory() { + _history.value = searchHistoryManager.findAll(showFolders = false) + .filterIsInstance() + .map(SearchHistoryEntry.SearchTerm::term) + } + suspend fun categoryPodcasts(categoryId: Int, source: String): List { return discoverFeedLoader.loadCategoryPodcasts(source, categoryId, syncManager.isLoggedIn()) } @@ -188,6 +248,10 @@ class TvSearchViewModel @Inject constructor( episodeManager.findByUuid(episode.uuid) } } + + companion object { + private const val SEARCH_DEBOUNCE_MS = 300L + } } private fun Podcast.toSearchItem() = ImprovedSearchResultItem.PodcastItem( @@ -198,6 +262,14 @@ private fun Podcast.toSearchItem() = ImprovedSearchResultItem.PodcastItem( isExplicit = explicit == true, ) +private fun SearchAutoCompleteItem.Podcast.toSearchItem() = ImprovedSearchResultItem.PodcastItem( + uuid = uuid, + title = title, + author = author, + isFollowed = isSubscribed, + isExplicit = isExplicit, +) + enum class TvSearchFilter( @StringRes val labelRes: Int, ) { @@ -214,5 +286,6 @@ sealed interface TvSearchState { data class Results( val podcasts: List, val episodes: List, + val isPartial: Boolean = false, ) : 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 8ee00e14f93..a2b0e1d6761 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 @@ -6,6 +6,8 @@ import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow import au.com.shiftyjelly.pocketcasts.models.entity.Podcast import au.com.shiftyjelly.pocketcasts.models.to.ImprovedSearchResultItem +import au.com.shiftyjelly.pocketcasts.models.to.SearchAutoCompleteItem +import au.com.shiftyjelly.pocketcasts.models.to.SearchHistoryEntry import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.preferences.UserSetting import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository @@ -13,6 +15,7 @@ import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.search.ImprovedSearchManager +import au.com.shiftyjelly.pocketcasts.repositories.searchhistory.SearchHistoryManager import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager import au.com.shiftyjelly.pocketcasts.servers.model.Discover import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory @@ -62,7 +65,6 @@ class TvSearchViewModelTest { } private val settings = mock { whenever(it.discoverCountryCode).thenReturn(discoverCountryCode) - whenever(it.getPodcastSearchDebounceMs()).thenReturn(0) } private val improvedSearchManager = mock() private val podcastManager = mock { @@ -70,6 +72,12 @@ class TvSearchViewModelTest { } private val episodeManager = mock() private val playbackManager = mock() + private val searchHistoryManager = mock() + + init { + whenever { improvedSearchManager.autoCompleteSearch(any()) }.thenReturn(emptyList()) + whenever { searchHistoryManager.findAll(any()) }.thenReturn(emptyList()) + } @Test fun `exposes the browse categories from the search feed row`() = runTest { @@ -281,7 +289,6 @@ class TvSearchViewModelTest { @Test fun `keystrokes within the debounce window only search the last term`() = runTest { - whenever(settings.getPodcastSearchDebounceMs()).thenReturn(300) whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) @@ -307,11 +314,11 @@ class TvSearchViewModelTest { } @Test - fun `subscribed podcasts lead the results and are not duplicated by the server`() = runTest { + fun `server results lead and subscribed podcasts fill the remaining gaps`() = runTest { whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) whenever(podcastManager.findSubscribedFlow(any())).thenReturn(flowOf(listOf(subscribedPodcast("podcast-1")))) whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn( - listOf(podcastItem("podcast-1"), podcastItem("podcast-2")), + listOf(podcastItem("podcast-2")), ) val viewModel = createViewModel() @@ -319,8 +326,78 @@ class TvSearchViewModelTest { advanceUntilIdle() val state = viewModel.searchState.value as TvSearchState.Results - assertEquals(listOf("podcast-1", "podcast-2"), state.podcasts.map { it.uuid }) - assertTrue(state.podcasts.first().isFollowed) + assertEquals(listOf("podcast-2", "podcast-1"), state.podcasts.map { it.uuid }) + } + + @Test + fun `predictive term results are exposed as suggestions`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.autoCompleteSearch(any()) }.thenReturn( + listOf(SearchAutoCompleteItem.Term("sugar rush"), SearchAutoCompleteItem.Term("sugar high")), + ) + whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) + + val viewModel = createViewModel() + viewModel.onQueryChange("sugar") + advanceUntilIdle() + + assertEquals(listOf("sugar rush", "sugar high"), viewModel.suggestions.value) + } + + @Test + fun `saving a search term persists it and exposes recent searches`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { searchHistoryManager.findAll(any()) }.thenReturn( + listOf(SearchHistoryEntry.SearchTerm(term = "sugar")), + ) + + val viewModel = createViewModel() + viewModel.saveSearchTerm("sugar") + advanceUntilIdle() + + verifyBlocking(searchHistoryManager) { add(any()) } + assertEquals(listOf("sugar"), viewModel.history.value) + } + + @Test + fun `a failure fetching subscribed podcasts surfaces the error state`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever(podcastManager.findSubscribedFlow(any())).thenThrow(RuntimeException("database unavailable")) + + val viewModel = createViewModel() + viewModel.onQueryChange("sugar") + advanceUntilIdle() + + assertEquals(TvSearchState.Error, viewModel.searchState.value) + } + + @Test + fun `searching does not save partial terms to history`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) + + val viewModel = createViewModel() + viewModel.onQueryChange("sug") + advanceUntilIdle() + + verifyBlocking(searchHistoryManager, never()) { add(any()) } + } + + @Test + fun `clearing the query clears the suggestions`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.autoCompleteSearch(any()) }.thenReturn( + listOf(SearchAutoCompleteItem.Term("sugar")), + ) + whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) + val viewModel = createViewModel() + viewModel.onQueryChange("sugar") + advanceUntilIdle() + assertTrue(viewModel.suggestions.value.isNotEmpty()) + + viewModel.onQueryChange("") + + assertTrue(viewModel.suggestions.value.isEmpty()) } @Test @@ -346,7 +423,7 @@ class TvSearchViewModelTest { } @Test - fun `clearing the query resets the filter to top results`() = runTest { + fun `clearing the query preserves the selected filter`() = runTest { whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) val viewModel = createViewModel() @@ -354,7 +431,7 @@ class TvSearchViewModelTest { viewModel.onQueryChange("") advanceUntilIdle() - assertEquals(TvSearchFilter.TopResults, viewModel.filter.value) + assertEquals(TvSearchFilter.Podcasts, viewModel.filter.value) } private fun podcastItem(uuid: String) = ImprovedSearchResultItem.PodcastItem( @@ -386,7 +463,7 @@ class TvSearchViewModelTest { podcastManager = podcastManager, episodeManager = episodeManager, playbackManager = playbackManager, - settings = settings, + searchHistoryManager = searchHistoryManager, ) private fun category(id: Int, name: String) = DiscoverCategory(id = id, name = name, icon = "", source = "")