diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml
index 7fa1f41ab94..75fc752b16c 100644
--- a/modules/services/localization/src/main/res/values/strings.xml
+++ b/modules/services/localization/src/main/res/values/strings.xml
@@ -2883,6 +2883,7 @@
Top Results
@string/podcasts
@string/episodes
+ @string/folders
View all for \"%1$s\"
No Results
Try a new search, or by adding a podcast feed URL
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
index 716454f8d65..8ada47c3bb4 100644
--- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchFilters.kt
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchFilters.kt
@@ -38,6 +38,7 @@ internal fun TvSearchFilters(
selected: TvSearchFilter,
onFilterSelect: (TvSearchFilter) -> Unit,
modifier: Modifier = Modifier,
+ filters: List = TvSearchFilter.entries,
upFocusRequester: FocusRequester? = null,
) {
Row(
@@ -47,6 +48,7 @@ internal fun TvSearchFilters(
FilterDivider(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(20.dp))
TvSearchFilterPills(
+ filters = filters,
selected = selected,
onFilterSelect = onFilterSelect,
upFocusRequester = upFocusRequester,
@@ -58,11 +60,12 @@ internal fun TvSearchFilters(
@Composable
private fun TvSearchFilterPills(
+ filters: List,
selected: TvSearchFilter,
onFilterSelect: (TvSearchFilter) -> Unit,
upFocusRequester: FocusRequester? = null,
) {
- val selectedIndex = TvSearchFilter.entries.indexOf(selected)
+ val selectedIndex = filters.indexOf(selected)
val focusRequester = remember { FocusRequester() }
Box(
modifier = Modifier
@@ -86,7 +89,7 @@ private fun TvSearchFilterPills(
}
},
) {
- TvSearchFilter.entries.forEachIndexed { index, filter ->
+ filters.forEachIndexed { index, filter ->
Tab(
selected = index == selectedIndex,
onFocus = { onFilterSelect(filter) },
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 4d022cf5c01..1447a76441c 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
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
@@ -51,6 +52,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.TvFolderCard
import au.com.shiftyjelly.pocketcasts.component.TvPodcastGridScaffold
import au.com.shiftyjelly.pocketcasts.component.TvPodcastTile
import au.com.shiftyjelly.pocketcasts.component.TvPodcastTileDefaults
@@ -63,8 +65,13 @@ import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow
import au.com.shiftyjelly.pocketcasts.discover.TvOpenedCategory
import au.com.shiftyjelly.pocketcasts.discover.TvOpenedCategorySaver
import au.com.shiftyjelly.pocketcasts.discover.tvDiscoverRow
+import au.com.shiftyjelly.pocketcasts.models.entity.Folder
+import au.com.shiftyjelly.pocketcasts.models.entity.Podcast
import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode
+import au.com.shiftyjelly.pocketcasts.models.to.FolderItem
import au.com.shiftyjelly.pocketcasts.models.to.ImprovedSearchResultItem
+import au.com.shiftyjelly.pocketcasts.models.type.PodcastsSortType
+import au.com.shiftyjelly.pocketcasts.podcasts.TvFolderDetailScreen
import au.com.shiftyjelly.pocketcasts.podcasts.TvPodcastDetailsScreen
import au.com.shiftyjelly.pocketcasts.repositories.images.PodcastImage
import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory
@@ -80,9 +87,17 @@ import au.com.shiftyjelly.pocketcasts.localization.R as LR
private val ContentHorizontalPadding = 48.dp
private val ContentPadding = PaddingValues(horizontal = ContentHorizontalPadding)
private const val SEARCH_ROW_LIMIT = 10
+private const val FOLDER_COVER_COUNT = 4
private val SearchEpisodeCardWidth = 360.dp
private const val EPISODE_GRID_COLUMNS = 2
+private data class SearchOpenedFolder(val uuid: String, val name: String)
+
+private val SearchOpenedFolderSaver = listSaver(
+ save = { folder -> folder?.let { listOf(it.uuid, it.name) } ?: emptyList() },
+ restore = { saved -> saved.takeIf { it.size == 2 }?.let { (uuid, name) -> SearchOpenedFolder(uuid, name) } },
+)
+
@Composable
fun TvSearchScreen(
modifier: Modifier = Modifier,
@@ -91,6 +106,7 @@ fun TvSearchScreen(
val query by viewModel.query.collectAsStateWithLifecycle()
val searchState by viewModel.searchState.collectAsStateWithLifecycle()
val filter by viewModel.filter.collectAsStateWithLifecycle()
+ val hasFolderResults by viewModel.hasFolderResults.collectAsStateWithLifecycle()
val categories by viewModel.categories.collectAsStateWithLifecycle()
val discoverRows by viewModel.discoverRows.collectAsStateWithLifecycle()
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
@@ -99,11 +115,14 @@ fun TvSearchScreen(
var openedPodcastUuid by rememberSaveable { mutableStateOf(null) }
var openedCategory by rememberSaveable(stateSaver = TvOpenedCategorySaver) { mutableStateOf(null) }
+ var openedFolder by rememberSaveable(stateSaver = SearchOpenedFolderSaver) { mutableStateOf(null) }
var detailsEpisode by remember { mutableStateOf(null) }
var restoreFocusTrigger by remember { mutableIntStateOf(0) }
var categoryRestoreTrigger by remember { mutableIntStateOf(0) }
+ var folderRestoreTrigger by remember { mutableIntStateOf(0) }
val podcastUuid = openedPodcastUuid
val category = openedCategory
+ val folder = openedFolder
val openNowPlaying = LocalOpenNowPlaying.current
val toastHostState = LocalTvToastHostState.current
@@ -120,11 +139,13 @@ fun TvSearchScreen(
query = query,
searchState = searchState,
filter = filter,
+ hasFolderResults = hasFolderResults,
categories = categories,
discoverRows = discoverRows,
onQueryChange = viewModel::onQueryChange,
onFilterSelect = viewModel::onFilterSelected,
onOpenPodcast = { openedPodcastUuid = it },
+ onOpenFolder = { openedFolder = SearchOpenedFolder(it.folder.uuid, it.folder.name) },
onOpenCategory = { openedCategory = TvOpenedCategory(it.id, it.name, it.source) },
onPlayEpisode = viewModel::playEpisode,
onOpenEpisodeActions = viewModel::openEpisodeActions,
@@ -140,8 +161,24 @@ fun TvSearchScreen(
modifier = Modifier
.fillMaxSize()
.padding(top = TvTopBarHeight)
- .tvFocusInactiveWhen(podcastUuid != null || category != null),
+ .tvFocusInactiveWhen(podcastUuid != null || category != null || folder != null),
)
+ TvDetailOverlay(
+ target = folder,
+ onBack = { openedFolder = null },
+ modifier = Modifier.tvFocusInactiveWhen(podcastUuid != null),
+ onHide = { restoreFocusTrigger++ },
+ ) { openFolder ->
+ TvFolderDetailScreen(
+ folderUuid = openFolder.uuid,
+ folderName = openFolder.name,
+ getFolderPodcasts = viewModel::folderPodcasts,
+ onOpenPodcast = { openedPodcastUuid = it },
+ onClose = { openedFolder = null },
+ onFolderImpression = {},
+ restoreFocusTrigger = folderRestoreTrigger,
+ )
+ }
TvDetailOverlay(
target = category,
onBack = { openedCategory = null },
@@ -160,7 +197,13 @@ fun TvSearchScreen(
TvDetailOverlay(
target = podcastUuid,
onBack = { openedPodcastUuid = null },
- onHide = { if (openedCategory != null) categoryRestoreTrigger++ else restoreFocusTrigger++ },
+ onHide = {
+ when {
+ openedCategory != null -> categoryRestoreTrigger++
+ openedFolder != null -> folderRestoreTrigger++
+ else -> restoreFocusTrigger++
+ }
+ },
) { uuid ->
TvPodcastDetailsScreen(
podcastUuid = uuid,
@@ -197,11 +240,13 @@ private fun TvSearchContent(
query: String,
searchState: TvSearchState,
filter: TvSearchFilter,
+ hasFolderResults: Boolean,
categories: List,
discoverRows: List,
onQueryChange: (String) -> Unit,
onFilterSelect: (TvSearchFilter) -> Unit,
onOpenPodcast: (String) -> Unit,
+ onOpenFolder: (FolderItem.Folder) -> Unit,
onOpenCategory: (DiscoverCategory) -> Unit,
onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit,
onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit,
@@ -249,10 +294,14 @@ private fun TvSearchContent(
Spacer(modifier = Modifier.height(24.dp))
}
+ val filters = remember(hasFolderResults) { TvSearchFilter.entries.filter { it != TvSearchFilter.Folders || hasFolderResults } }
+ val effectiveFilter = if (filter in filters) filter else TvSearchFilter.TopResults
+
if (searchState !is TvSearchState.Idle) {
TvSearchFilters(
- selected = filter,
+ selected = effectiveFilter,
onFilterSelect = onFilterSelect,
+ filters = filters,
modifier = Modifier.padding(ContentPadding),
upFocusRequester = searchFieldFocusRequester,
)
@@ -291,9 +340,10 @@ private fun TvSearchContent(
is TvSearchState.Results -> TvSearchResults(
results = searchState,
- filter = filter,
+ filter = effectiveFilter,
searchTerm = query.trim(),
onOpenPodcast = onOpenPodcast,
+ onOpenFolder = onOpenFolder,
onPlayEpisode = onPlayEpisode,
onOpenEpisodeActions = onOpenEpisodeActions,
restoreFocusTrigger = restoreFocusTrigger,
@@ -417,6 +467,7 @@ private fun TvSearchResults(
filter: TvSearchFilter,
searchTerm: String,
onOpenPodcast: (String) -> Unit,
+ onOpenFolder: (FolderItem.Folder) -> Unit,
onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit,
onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit,
restoreFocusTrigger: Int,
@@ -425,7 +476,9 @@ private fun TvSearchResults(
TvSearchFilter.TopResults -> TvSearchTopResults(
podcasts = results.podcasts,
episodes = results.episodes,
+ folders = results.folders,
onOpenPodcast = onOpenPodcast,
+ onOpenFolder = onOpenFolder,
onPlayEpisode = onPlayEpisode,
onOpenEpisodeActions = onOpenEpisodeActions,
restoreFocusTrigger = restoreFocusTrigger,
@@ -475,6 +528,32 @@ private fun TvSearchResults(
restoreFocusTrigger = restoreFocusTrigger,
)
}
+
+ TvSearchFilter.Folders -> if (results.folders.isEmpty()) {
+ 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.folders.map { it.folder.uuid },
+ modifier = Modifier.fillMaxSize(),
+ horizontalContentPadding = ContentHorizontalPadding,
+ restoreFocusTrigger = restoreFocusTrigger,
+ ) { index, itemModifier ->
+ val folderItem = results.folders[index]
+ TvFolderCard(
+ folder = folderItem.folder,
+ coverUrls = folderItem.podcasts.take(FOLDER_COVER_COUNT).map { PodcastImage.getMediumArtworkUrl(it.uuid) },
+ onClick = { onOpenFolder(folderItem) },
+ modifier = itemModifier,
+ )
+ }
+ }
}
}
@@ -482,7 +561,9 @@ private fun TvSearchResults(
private fun TvSearchTopResults(
podcasts: List,
episodes: List,
+ folders: List,
onOpenPodcast: (String) -> Unit,
+ onOpenFolder: (FolderItem.Folder) -> Unit,
onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit,
onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit,
restoreFocusTrigger: Int,
@@ -499,10 +580,12 @@ private fun TvSearchTopResults(
val featured = episodes.filter { it.hasVideo }.take(SEARCH_ROW_LIMIT)
val otherEpisodes = episodes.filterNot { it.hasVideo }.take(SEARCH_ROW_LIMIT)
+ val topFolders = folders.take(SEARCH_ROW_LIMIT)
val topPodcasts = podcasts.take(SEARCH_ROW_LIMIT)
val featuredFirst = featured.isNotEmpty()
val episodesFirst = !featuredFirst && otherEpisodes.isNotEmpty()
- val podcastsFirst = !featuredFirst && !episodesFirst
+ val foldersFirst = !featuredFirst && !episodesFirst && topFolders.isNotEmpty()
+ val podcastsFirst = !featuredFirst && !episodesFirst && !foldersFirst
LazyColumn(modifier = Modifier.fillMaxSize()) {
item { Spacer(modifier = Modifier.height(8.dp)) }
@@ -529,6 +612,14 @@ private fun TvSearchTopResults(
)
}
}
+ if (topFolders.isNotEmpty()) {
+ item { Spacer(modifier = Modifier.height(24.dp)) }
+ tvSearchFoldersRow(
+ folders = topFolders,
+ onOpenFolder = onOpenFolder,
+ focusRequester = restoreFocusRequester.takeIf { foldersFirst },
+ )
+ }
if (topPodcasts.isNotEmpty()) {
item { Spacer(modifier = Modifier.height(24.dp)) }
tvSearchPodcastsRow(
@@ -541,6 +632,29 @@ private fun TvSearchTopResults(
}
}
+private fun LazyListScope.tvSearchFoldersRow(
+ folders: List,
+ onOpenFolder: (FolderItem.Folder) -> Unit,
+ focusRequester: FocusRequester?,
+) {
+ item {
+ TvRow(
+ title = stringResource(LR.string.folders),
+ items = folders,
+ contentPadding = ContentPadding,
+ key = { it.folder.uuid },
+ focusRequester = focusRequester,
+ ) { folderItem ->
+ TvFolderCard(
+ folder = folderItem.folder,
+ coverUrls = folderItem.podcasts.take(FOLDER_COVER_COUNT).map { PodcastImage.getMediumArtworkUrl(it.uuid) },
+ onClick = { onOpenFolder(folderItem) },
+ modifier = Modifier.width(TvPodcastTileDefaults.RowImageWidth),
+ )
+ }
+ }
+}
+
@Composable
private fun TvSearchEpisodeCarousel(
title: String,
@@ -717,6 +831,7 @@ private fun TvSearchScreenPreview() {
query = "",
searchState = TvSearchState.Idle,
filter = TvSearchFilter.TopResults,
+ hasFolderResults = false,
categories = listOf(
DiscoverCategory(id = 1, name = "Comedy", icon = "", source = ""),
DiscoverCategory(id = 2, name = "True Crime", icon = "", source = ""),
@@ -726,6 +841,7 @@ private fun TvSearchScreenPreview() {
onQueryChange = {},
onFilterSelect = {},
onOpenPodcast = {},
+ onOpenFolder = {},
onOpenCategory = {},
onPlayEpisode = {},
onOpenEpisodeActions = {},
@@ -743,6 +859,7 @@ private fun TvSearchIdleWithHistoryPreview() {
query = "",
searchState = TvSearchState.Idle,
filter = TvSearchFilter.TopResults,
+ hasFolderResults = false,
categories = emptyList(),
discoverRows = emptyList(),
onQueryChange = {},
@@ -751,6 +868,7 @@ private fun TvSearchIdleWithHistoryPreview() {
onOpenCategory = {},
onPlayEpisode = {},
onOpenEpisodeActions = {},
+ onOpenFolder = {},
history = listOf("Freakonomics", "Business Daily", "Science Weekly"),
)
}
@@ -784,8 +902,24 @@ private fun TvSearchResultsPreview() {
hasVideo = it == 0,
)
},
+ folders = listOf(
+ FolderItem.Folder(
+ folder = Folder(
+ uuid = "folder-1",
+ name = "Business & Finance",
+ color = 3,
+ addedDate = Date(0),
+ sortPosition = 0,
+ podcastsSortType = PodcastsSortType.NAME_A_TO_Z,
+ deleted = false,
+ syncModified = 0,
+ ),
+ podcasts = List(4) { Podcast(uuid = "folder-podcast-$it") },
+ ),
+ ),
),
filter = TvSearchFilter.TopResults,
+ hasFolderResults = true,
categories = emptyList(),
discoverRows = emptyList(),
onQueryChange = {},
@@ -794,6 +928,7 @@ private fun TvSearchResultsPreview() {
onOpenCategory = {},
onPlayEpisode = {},
onOpenEpisodeActions = {},
+ onOpenFolder = {},
)
}
}
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 774cb9712f4..338196b5485 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,11 +10,14 @@ import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverPodcast
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.FolderItem
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.models.type.PodcastsSortType
import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager
import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager
+import au.com.shiftyjelly.pocketcasts.repositories.podcast.FolderManager
import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager
import au.com.shiftyjelly.pocketcasts.repositories.search.ImprovedSearchManager
import au.com.shiftyjelly.pocketcasts.repositories.searchhistory.SearchHistoryManager
@@ -47,6 +50,7 @@ class TvSearchViewModel @Inject constructor(
private val episodeManager: EpisodeManager,
private val playbackManager: PlaybackManager,
private val searchHistoryManager: SearchHistoryManager,
+ private val folderManager: FolderManager,
) : ViewModel() {
private val _categories = MutableStateFlow>(emptyList())
@@ -64,6 +68,9 @@ class TvSearchViewModel @Inject constructor(
private val _filter = MutableStateFlow(TvSearchFilter.TopResults)
val filter: StateFlow = _filter.asStateFlow()
+ private val _hasFolderResults = MutableStateFlow(false)
+ val hasFolderResults: StateFlow = _hasFolderResults.asStateFlow()
+
private val _suggestions = MutableStateFlow>(emptyList())
val suggestions: StateFlow> = _suggestions.asStateFlow()
@@ -123,6 +130,7 @@ class TvSearchViewModel @Inject constructor(
if (term.isEmpty()) {
_suggestions.value = emptyList()
_searchState.value = TvSearchState.Idle
+ updateFolderResults(hasFolders = false)
return
}
searchJob = viewModelScope.launch {
@@ -130,6 +138,16 @@ class TvSearchViewModel @Inject constructor(
_searchState.value = TvSearchState.Searching
_searchState.value = try {
val fullSearch = async { runCatching { improvedSearchManager.combinedSearch(term) } }
+ val foldersSearch = async {
+ try {
+ searchFolders(term)
+ } catch (exception: CancellationException) {
+ throw exception
+ } catch (exception: Exception) {
+ Timber.e(exception, "Failed to search TV folders")
+ emptyList()
+ }
+ }
val localPodcasts = podcastManager.findSubscribedFlow(term).first().map(Podcast::toSearchItem)
val localUuids = localPodcasts.mapTo(HashSet(), ImprovedSearchResultItem.PodcastItem::uuid)
val predictiveResults = try {
@@ -156,15 +174,18 @@ class TvSearchViewModel @Inject constructor(
.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()) {
+ val folders = foldersSearch.await()
+ updateFolderResults(hasFolders = folders.isNotEmpty())
+ if (podcasts.isEmpty() && episodes.isEmpty() && folders.isEmpty()) {
TvSearchState.NoResults
} else {
- TvSearchState.Results(podcasts = podcasts, episodes = episodes)
+ TvSearchState.Results(podcasts = podcasts, episodes = episodes, folders = folders)
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
Timber.e(exception, "Failed to search on TV")
+ updateFolderResults(hasFolders = false)
TvSearchState.Error
}
}
@@ -174,6 +195,27 @@ class TvSearchViewModel @Inject constructor(
_filter.value = filter
}
+ private fun updateFolderResults(hasFolders: Boolean) {
+ _hasFolderResults.value = hasFolders
+ if (!hasFolders && _filter.value == TvSearchFilter.Folders) {
+ _filter.value = TvSearchFilter.TopResults
+ }
+ }
+
+ private suspend fun searchFolders(term: String): List {
+ if (!syncManager.isLoggedIn()) {
+ return emptyList()
+ }
+ return folderManager.getAll()
+ .filter { it.name.contains(term, ignoreCase = true) }
+ .sortedBy { PodcastsSortType.cleanStringForSort(it.name) }
+ .map { folder -> FolderItem.Folder(folder = folder, podcasts = folderManager.findFolderPodcastsSorted(folder.uuid)) }
+ }
+
+ suspend fun folderPodcasts(folderUuid: String): List {
+ return folderManager.findFolderPodcastsSorted(folderUuid)
+ }
+
fun saveSearchTerm(term: String) {
val trimmed = term.trim()
if (trimmed.isEmpty()) {
@@ -279,6 +321,7 @@ enum class TvSearchFilter(
TopResults(LR.string.search_filters_top_results),
Podcasts(LR.string.search_filters_podcasts),
Episodes(LR.string.search_filters_episodes),
+ Folders(LR.string.search_filters_folders),
}
sealed interface TvSearchState {
@@ -289,6 +332,7 @@ sealed interface TvSearchState {
data class Results(
val podcasts: List,
val episodes: List,
+ val folders: List = emptyList(),
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 307432c8575..fa508c22825 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
@@ -4,15 +4,18 @@ import android.content.Context
import android.content.res.Resources
import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader
import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow
+import au.com.shiftyjelly.pocketcasts.models.entity.Folder
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.models.type.PodcastsSortType
import au.com.shiftyjelly.pocketcasts.preferences.Settings
import au.com.shiftyjelly.pocketcasts.preferences.UserSetting
import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository
import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager
import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager
+import au.com.shiftyjelly.pocketcasts.repositories.podcast.FolderManager
import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager
import au.com.shiftyjelly.pocketcasts.repositories.search.ImprovedSearchManager
import au.com.shiftyjelly.pocketcasts.repositories.searchhistory.SearchHistoryManager
@@ -34,6 +37,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -73,6 +77,7 @@ class TvSearchViewModelTest {
private val episodeManager = mock()
private val playbackManager = mock()
private val searchHistoryManager = mock()
+ private val folderManager = mock()
init {
whenever { improvedSearchManager.autoCompleteSearch(any()) }.thenReturn(emptyList())
@@ -452,6 +457,132 @@ class TvSearchViewModelTest {
private fun subscribedPodcast(uuid: String) = Podcast(uuid = uuid, title = "Podcast $uuid", author = "Author")
+ @Test
+ fun `matching folders are surfaced for a signed-in user`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover())
+ whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList())
+ whenever(syncManager.isLoggedIn()).thenReturn(true)
+ whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar Shows"), folderEntity("Comedy")))
+ whenever { folderManager.findFolderPodcastsSorted(any()) }.thenReturn(emptyList())
+
+ val viewModel = createViewModel()
+ viewModel.onQueryChange("sugar")
+ advanceUntilIdle()
+
+ val state = viewModel.searchState.value as TvSearchState.Results
+ assertEquals(listOf("Sugar Shows"), state.folders.map { it.folder.name })
+ }
+
+ @Test
+ fun `matching folders are sorted by name`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover())
+ whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList())
+ whenever(syncManager.isLoggedIn()).thenReturn(true)
+ whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar Rush"), folderEntity("Sugar Beats")))
+ whenever { folderManager.findFolderPodcastsSorted(any()) }.thenReturn(emptyList())
+
+ val viewModel = createViewModel()
+ viewModel.onQueryChange("sugar")
+ advanceUntilIdle()
+
+ val state = viewModel.searchState.value as TvSearchState.Results
+ assertEquals(listOf("Sugar Beats", "Sugar Rush"), state.folders.map { it.folder.name })
+ }
+
+ @Test
+ fun `folders are not searched for a signed-out user`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover())
+ whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(listOf(podcastItem("podcast-1")))
+
+ val viewModel = createViewModel()
+ viewModel.onQueryChange("sugar")
+ advanceUntilIdle()
+
+ val state = viewModel.searchState.value as TvSearchState.Results
+ assertTrue(state.folders.isEmpty())
+ verifyBlocking(folderManager, never()) { getAll() }
+ }
+
+ @Test
+ fun `a matching folder alone counts as a result`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover())
+ whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList())
+ whenever(syncManager.isLoggedIn()).thenReturn(true)
+ whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar")))
+ whenever { folderManager.findFolderPodcastsSorted(any()) }.thenReturn(emptyList())
+
+ val viewModel = createViewModel()
+ viewModel.onQueryChange("sugar")
+ advanceUntilIdle()
+
+ assertTrue(viewModel.searchState.value is TvSearchState.Results)
+ }
+
+ @Test
+ fun `a folder search failure does not fail the whole search`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover())
+ whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(listOf(podcastItem("podcast-1")))
+ whenever(syncManager.isLoggedIn()).thenReturn(true)
+ whenever { folderManager.getAll() }.thenThrow(RuntimeException("database unavailable"))
+
+ val viewModel = createViewModel()
+ viewModel.onQueryChange("sugar")
+ advanceUntilIdle()
+
+ val state = viewModel.searchState.value as TvSearchState.Results
+ assertEquals(listOf("podcast-1"), state.podcasts.map { it.uuid })
+ assertTrue(state.folders.isEmpty())
+ }
+
+ @Test
+ fun `hasFolderResults reflects whether the terminal search found folders`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover())
+ whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList())
+ whenever(syncManager.isLoggedIn()).thenReturn(true)
+ whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar")))
+ whenever { folderManager.findFolderPodcastsSorted(any()) }.thenReturn(emptyList())
+
+ val viewModel = createViewModel()
+ viewModel.onQueryChange("sugar")
+ advanceUntilIdle()
+ assertTrue(viewModel.hasFolderResults.value)
+
+ viewModel.onQueryChange("")
+ assertFalse(viewModel.hasFolderResults.value)
+ }
+
+ @Test
+ fun `a terminal search without folders resets the folders filter`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover())
+ whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(listOf(podcastItem("podcast-1")))
+ whenever(syncManager.isLoggedIn()).thenReturn(true)
+ whenever { folderManager.getAll() }.thenReturn(emptyList())
+
+ val viewModel = createViewModel()
+ viewModel.onFilterSelected(TvSearchFilter.Folders)
+ viewModel.onQueryChange("sugar")
+ advanceUntilIdle()
+
+ assertEquals(TvSearchFilter.TopResults, viewModel.filter.value)
+ assertFalse(viewModel.hasFolderResults.value)
+ }
+
+ @Test
+ fun `a failed search clears folder results and resets the folders filter`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover())
+ whenever { improvedSearchManager.combinedSearch(any()) }.thenThrow(RuntimeException("network"))
+ whenever(syncManager.isLoggedIn()).thenReturn(true)
+
+ val viewModel = createViewModel()
+ viewModel.onFilterSelected(TvSearchFilter.Folders)
+ viewModel.onQueryChange("sugar")
+ advanceUntilIdle()
+
+ assertTrue(viewModel.searchState.value is TvSearchState.Error)
+ assertEquals(TvSearchFilter.TopResults, viewModel.filter.value)
+ assertFalse(viewModel.hasFolderResults.value)
+ }
+
private fun createViewModel() = TvSearchViewModel(
discoverFeedLoader = TvDiscoverFeedLoader(
listRepository = listRepository,
@@ -464,6 +595,18 @@ class TvSearchViewModelTest {
episodeManager = episodeManager,
playbackManager = playbackManager,
searchHistoryManager = searchHistoryManager,
+ folderManager = folderManager,
+ )
+
+ private fun folderEntity(name: String) = Folder(
+ uuid = name,
+ name = name,
+ color = 0,
+ addedDate = Date(0),
+ sortPosition = 0,
+ podcastsSortType = PodcastsSortType.NAME_A_TO_Z,
+ deleted = false,
+ syncModified = 0,
)
private fun category(id: Int, name: String) = DiscoverCategory(id = id, name = name, icon = "", source = "")