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 = "")