From febe6aa7e5618f3f07afbdac0b7998450987b8ec Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Fri, 14 Aug 2026 21:47:22 +0200 Subject: [PATCH 1/8] Search local folders for Plus users on TV --- .../pocketcasts/search/TvSearchViewModel.kt | 24 ++++++- .../search/TvSearchViewModelTest.kt | 70 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) 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..05f19d54a50 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,15 +10,18 @@ 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.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 import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager +import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -47,6 +50,8 @@ class TvSearchViewModel @Inject constructor( private val episodeManager: EpisodeManager, private val playbackManager: PlaybackManager, private val searchHistoryManager: SearchHistoryManager, + private val folderManager: FolderManager, + private val userManager: UserManager, ) : ViewModel() { private val _categories = MutableStateFlow>(emptyList()) @@ -149,6 +154,7 @@ class TvSearchViewModel @Inject constructor( _searchState.value = TvSearchState.Results(podcasts = earlyPodcasts, episodes = emptyList(), isPartial = true) } + val folders = searchFolders(term) val remoteResults = fullSearch.await().getOrThrow() val remotePodcasts = remoteResults.filterIsInstance() val podcasts = (predictivePodcasts + remotePodcasts + localPodcasts) @@ -156,10 +162,10 @@ 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()) { + 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 @@ -174,6 +180,19 @@ class TvSearchViewModel @Inject constructor( _filter.value = filter } + private suspend fun searchFolders(term: String): List { + if (!userManager.getSignInState().firstOrError().await().isSignedInAsPlusOrPatron) { + return emptyList() + } + return folderManager.getAll() + .filter { it.name.contains(term, ignoreCase = true) } + .map { folder -> FolderItem.Folder(folder = folder, podcasts = podcastManager.findPodcastsInFolder(folder.uuid)) } + } + + suspend fun folderPodcasts(folderUuid: String): List { + return folderManager.findFolderPodcastsSorted(folderUuid) + } + fun saveSearchTerm(term: String) { val trimmed = term.trim() if (trimmed.isEmpty()) { @@ -289,6 +308,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..f84959ae824 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,19 +4,25 @@ 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.models.type.SignInState +import au.com.shiftyjelly.pocketcasts.models.type.Subscription 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 import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager +import au.com.shiftyjelly.pocketcasts.repositories.user.UserManager import au.com.shiftyjelly.pocketcasts.servers.model.Discover import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverPodcast @@ -27,6 +33,7 @@ import au.com.shiftyjelly.pocketcasts.servers.model.ExpandedStyle import au.com.shiftyjelly.pocketcasts.servers.model.ListFeed import au.com.shiftyjelly.pocketcasts.servers.model.ListType import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule +import io.reactivex.Flowable import java.util.Date import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -73,10 +80,13 @@ class TvSearchViewModelTest { private val episodeManager = mock() private val playbackManager = mock() private val searchHistoryManager = mock() + private val folderManager = mock() + private val userManager = mock() init { whenever { improvedSearchManager.autoCompleteSearch(any()) }.thenReturn(emptyList()) whenever { searchHistoryManager.findAll(any()) }.thenReturn(emptyList()) + whenever(userManager.getSignInState()).thenReturn(Flowable.just(SignInState.SignedOut)) } @Test @@ -452,6 +462,51 @@ class TvSearchViewModelTest { private fun subscribedPodcast(uuid: String) = Podcast(uuid = uuid, title = "Podcast $uuid", author = "Author") + @Test + fun `matching folders are surfaced for a plus or patron user`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) + whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) + whenever(userManager.getSignInState()).thenReturn(Flowable.just(plusSignInState())) + whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar Shows"), folderEntity("Comedy"))) + whenever { podcastManager.findPodcastsInFolder(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 `folders are not searched for a non-plus 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(userManager.getSignInState()).thenReturn(Flowable.just(plusSignInState())) + whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar"))) + whenever { podcastManager.findPodcastsInFolder(any()) }.thenReturn(emptyList()) + + val viewModel = createViewModel() + viewModel.onQueryChange("sugar") + advanceUntilIdle() + + assertTrue(viewModel.searchState.value is TvSearchState.Results) + } + private fun createViewModel() = TvSearchViewModel( discoverFeedLoader = TvDiscoverFeedLoader( listRepository = listRepository, @@ -464,6 +519,21 @@ class TvSearchViewModelTest { episodeManager = episodeManager, playbackManager = playbackManager, searchHistoryManager = searchHistoryManager, + folderManager = folderManager, + userManager = userManager, + ) + + private fun plusSignInState() = SignInState.SignedIn(email = "test@example.com", subscription = Subscription.PlusPreview) + + 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 = "") From 4061b3ae53fbb05d1f7df2442e9139c1e62d60bf Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Fri, 14 Aug 2026 21:50:57 +0200 Subject: [PATCH 2/8] Show matching folders in TV search top results --- .../pocketcasts/search/TvSearchScreen.kt | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) 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..fca57155486 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 @@ -64,7 +66,9 @@ 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.PodcastEpisode +import au.com.shiftyjelly.pocketcasts.models.to.FolderItem import au.com.shiftyjelly.pocketcasts.models.to.ImprovedSearchResultItem +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 +84,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, @@ -99,11 +111,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 @@ -125,6 +140,7 @@ fun TvSearchScreen( 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 +156,23 @@ 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 }, + restoreFocusTrigger = folderRestoreTrigger, + ) + } TvDetailOverlay( target = category, onBack = { openedCategory = null }, @@ -160,7 +191,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, @@ -202,6 +239,7 @@ private fun TvSearchContent( onQueryChange: (String) -> Unit, onFilterSelect: (TvSearchFilter) -> Unit, onOpenPodcast: (String) -> Unit, + onOpenFolder: (FolderItem.Folder) -> Unit, onOpenCategory: (DiscoverCategory) -> Unit, onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit, onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit, @@ -294,6 +332,7 @@ private fun TvSearchContent( filter = filter, searchTerm = query.trim(), onOpenPodcast = onOpenPodcast, + onOpenFolder = onOpenFolder, onPlayEpisode = onPlayEpisode, onOpenEpisodeActions = onOpenEpisodeActions, restoreFocusTrigger = restoreFocusTrigger, @@ -417,6 +456,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 +465,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, @@ -482,7 +524,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 +543,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 +575,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 +595,28 @@ 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) }, + ) + } + } +} + @Composable private fun TvSearchEpisodeCarousel( title: String, @@ -726,6 +802,7 @@ private fun TvSearchScreenPreview() { onQueryChange = {}, onFilterSelect = {}, onOpenPodcast = {}, + onOpenFolder = {}, onOpenCategory = {}, onPlayEpisode = {}, onOpenEpisodeActions = {}, From 452970a3c51080acc7508e451db69d6f1551f2c4 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Fri, 14 Aug 2026 21:57:04 +0200 Subject: [PATCH 3/8] Search folders concurrently and degrade gracefully on failure --- .../au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 05f19d54a50..b8bed4a6880 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 @@ -135,6 +135,7 @@ class TvSearchViewModel @Inject constructor( _searchState.value = TvSearchState.Searching _searchState.value = try { val fullSearch = async { runCatching { improvedSearchManager.combinedSearch(term) } } + val foldersSearch = async { runCatching { searchFolders(term) }.getOrDefault(emptyList()) } val localPodcasts = podcastManager.findSubscribedFlow(term).first().map(Podcast::toSearchItem) val localUuids = localPodcasts.mapTo(HashSet(), ImprovedSearchResultItem.PodcastItem::uuid) val predictiveResults = try { @@ -154,7 +155,6 @@ class TvSearchViewModel @Inject constructor( _searchState.value = TvSearchState.Results(podcasts = earlyPodcasts, episodes = emptyList(), isPartial = true) } - val folders = searchFolders(term) val remoteResults = fullSearch.await().getOrThrow() val remotePodcasts = remoteResults.filterIsInstance() val podcasts = (predictivePodcasts + remotePodcasts + localPodcasts) @@ -162,6 +162,7 @@ class TvSearchViewModel @Inject constructor( .map { if (it.uuid in localUuids) it.copy(isFollowed = true) else it } val episodes = remoteResults.filterIsInstance() .distinctBy(ImprovedSearchResultItem.EpisodeItem::uuid) + val folders = foldersSearch.await() if (podcasts.isEmpty() && episodes.isEmpty() && folders.isEmpty()) { TvSearchState.NoResults } else { From cd8056b99615ee0285b2d782437f2a194fabe165 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Mon, 24 Aug 2026 12:35:00 +0200 Subject: [PATCH 4/8] Address PR comments --- .../pocketcasts/search/TvSearchScreen.kt | 22 +++++++++++++++++++ .../pocketcasts/search/TvSearchViewModel.kt | 20 +++++++++++++++-- .../search/TvSearchViewModelTest.kt | 16 ++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) 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 fca57155486..1720419d8a8 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 @@ -65,9 +65,12 @@ 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 @@ -170,6 +173,7 @@ fun TvSearchScreen( getFolderPodcasts = viewModel::folderPodcasts, onOpenPodcast = { openedPodcastUuid = it }, onClose = { openedFolder = null }, + onFolderImpression = {}, restoreFocusTrigger = folderRestoreTrigger, ) } @@ -612,6 +616,7 @@ private fun LazyListScope.tvSearchFoldersRow( folder = folderItem.folder, coverUrls = folderItem.podcasts.take(FOLDER_COVER_COUNT).map { PodcastImage.getMediumArtworkUrl(it.uuid) }, onClick = { onOpenFolder(folderItem) }, + modifier = Modifier.width(TvPodcastTileDefaults.RowImageWidth), ) } } @@ -828,6 +833,7 @@ private fun TvSearchIdleWithHistoryPreview() { onOpenCategory = {}, onPlayEpisode = {}, onOpenEpisodeActions = {}, + onOpenFolder = {}, history = listOf("Freakonomics", "Business Daily", "Science Weekly"), ) } @@ -861,6 +867,21 @@ 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, categories = emptyList(), @@ -871,6 +892,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 b8bed4a6880..522ff9591c3 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 @@ -14,6 +14,7 @@ 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.SignInState import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.FolderManager @@ -32,11 +33,14 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.rx2.await import timber.log.Timber import au.com.shiftyjelly.pocketcasts.localization.R as LR @@ -86,6 +90,9 @@ class TvSearchViewModel @Inject constructor( private var searchJob: Job? = null + private val signInState = userManager.getSignInState().asFlow() + .stateIn(viewModelScope, SharingStarted.Eagerly, SignInState.SignedOut) + init { viewModelScope.launch { val discover = try { @@ -135,7 +142,16 @@ class TvSearchViewModel @Inject constructor( _searchState.value = TvSearchState.Searching _searchState.value = try { val fullSearch = async { runCatching { improvedSearchManager.combinedSearch(term) } } - val foldersSearch = async { runCatching { searchFolders(term) }.getOrDefault(emptyList()) } + 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 { @@ -182,7 +198,7 @@ class TvSearchViewModel @Inject constructor( } private suspend fun searchFolders(term: String): List { - if (!userManager.getSignInState().firstOrError().await().isSignedInAsPlusOrPatron) { + if (!signInState.value.isSignedInAsPlusOrPatron) { return emptyList() } return folderManager.getAll() 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 f84959ae824..bfb1540074d 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 @@ -507,6 +507,22 @@ class TvSearchViewModelTest { 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(userManager.getSignInState()).thenReturn(Flowable.just(plusSignInState())) + 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()) + } + private fun createViewModel() = TvSearchViewModel( discoverFeedLoader = TvDiscoverFeedLoader( listRepository = listRepository, From d96679c621c6d01172c2a29f28fde90cac902a94 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Mon, 24 Aug 2026 17:37:37 +0200 Subject: [PATCH 5/8] Gate TV folder search on login instead of subscription tier --- .../pocketcasts/search/TvSearchViewModel.kt | 11 +---------- .../search/TvSearchViewModelTest.kt | 19 +++++-------------- 2 files changed, 6 insertions(+), 24 deletions(-) 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 522ff9591c3..1b1367a04d5 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 @@ -14,7 +14,6 @@ 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.SignInState import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.FolderManager @@ -22,7 +21,6 @@ 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.repositories.user.UserManager import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -33,14 +31,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.rx2.await import timber.log.Timber import au.com.shiftyjelly.pocketcasts.localization.R as LR @@ -55,7 +50,6 @@ class TvSearchViewModel @Inject constructor( private val playbackManager: PlaybackManager, private val searchHistoryManager: SearchHistoryManager, private val folderManager: FolderManager, - private val userManager: UserManager, ) : ViewModel() { private val _categories = MutableStateFlow>(emptyList()) @@ -90,9 +84,6 @@ class TvSearchViewModel @Inject constructor( private var searchJob: Job? = null - private val signInState = userManager.getSignInState().asFlow() - .stateIn(viewModelScope, SharingStarted.Eagerly, SignInState.SignedOut) - init { viewModelScope.launch { val discover = try { @@ -198,7 +189,7 @@ class TvSearchViewModel @Inject constructor( } private suspend fun searchFolders(term: String): List { - if (!signInState.value.isSignedInAsPlusOrPatron) { + if (!syncManager.isLoggedIn()) { return emptyList() } return folderManager.getAll() 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 bfb1540074d..e005d1f0e72 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 @@ -10,8 +10,6 @@ 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.models.type.SignInState -import au.com.shiftyjelly.pocketcasts.models.type.Subscription import au.com.shiftyjelly.pocketcasts.preferences.Settings import au.com.shiftyjelly.pocketcasts.preferences.UserSetting import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository @@ -22,7 +20,6 @@ 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.repositories.user.UserManager import au.com.shiftyjelly.pocketcasts.servers.model.Discover import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverPodcast @@ -33,7 +30,6 @@ import au.com.shiftyjelly.pocketcasts.servers.model.ExpandedStyle import au.com.shiftyjelly.pocketcasts.servers.model.ListFeed import au.com.shiftyjelly.pocketcasts.servers.model.ListType import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule -import io.reactivex.Flowable import java.util.Date import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -81,12 +77,10 @@ class TvSearchViewModelTest { private val playbackManager = mock() private val searchHistoryManager = mock() private val folderManager = mock() - private val userManager = mock() init { whenever { improvedSearchManager.autoCompleteSearch(any()) }.thenReturn(emptyList()) whenever { searchHistoryManager.findAll(any()) }.thenReturn(emptyList()) - whenever(userManager.getSignInState()).thenReturn(Flowable.just(SignInState.SignedOut)) } @Test @@ -463,10 +457,10 @@ class TvSearchViewModelTest { private fun subscribedPodcast(uuid: String) = Podcast(uuid = uuid, title = "Podcast $uuid", author = "Author") @Test - fun `matching folders are surfaced for a plus or patron user`() = runTest { + fun `matching folders are surfaced for a signed-in user`() = runTest { whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) - whenever(userManager.getSignInState()).thenReturn(Flowable.just(plusSignInState())) + whenever(syncManager.isLoggedIn()).thenReturn(true) whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar Shows"), folderEntity("Comedy"))) whenever { podcastManager.findPodcastsInFolder(any()) }.thenReturn(emptyList()) @@ -479,7 +473,7 @@ class TvSearchViewModelTest { } @Test - fun `folders are not searched for a non-plus user`() = runTest { + 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"))) @@ -496,7 +490,7 @@ class TvSearchViewModelTest { fun `a matching folder alone counts as a result`() = runTest { whenever(listRepository.getSearchDiscoverFeed()).thenReturn(discover()) whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) - whenever(userManager.getSignInState()).thenReturn(Flowable.just(plusSignInState())) + whenever(syncManager.isLoggedIn()).thenReturn(true) whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar"))) whenever { podcastManager.findPodcastsInFolder(any()) }.thenReturn(emptyList()) @@ -511,7 +505,7 @@ class TvSearchViewModelTest { 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(userManager.getSignInState()).thenReturn(Flowable.just(plusSignInState())) + whenever(syncManager.isLoggedIn()).thenReturn(true) whenever { folderManager.getAll() }.thenThrow(RuntimeException("database unavailable")) val viewModel = createViewModel() @@ -536,11 +530,8 @@ class TvSearchViewModelTest { playbackManager = playbackManager, searchHistoryManager = searchHistoryManager, folderManager = folderManager, - userManager = userManager, ) - private fun plusSignInState() = SignInState.SignedIn(email = "test@example.com", subscription = Subscription.PlusPreview) - private fun folderEntity(name: String) = Folder( uuid = name, name = name, From e5a366797b9a13d8460fee156de8bc6ed3ff6268 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Mon, 24 Aug 2026 17:48:37 +0200 Subject: [PATCH 6/8] Add a Folders filter to TV search when folder results exist --- .../src/main/res/values/strings.xml | 1 + .../pocketcasts/search/TvSearchFilters.kt | 7 ++++-- .../pocketcasts/search/TvSearchScreen.kt | 24 +++++++++++++++++-- .../pocketcasts/search/TvSearchViewModel.kt | 1 + 4 files changed, 29 insertions(+), 4 deletions(-) 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 1720419d8a8..cf5750e1887 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 @@ -291,10 +291,15 @@ private fun TvSearchContent( Spacer(modifier = Modifier.height(24.dp)) } + val hasFolders = (searchState as? TvSearchState.Results)?.folders?.isNotEmpty() == true + val filters = TvSearchFilter.entries.filter { it != TvSearchFilter.Folders || hasFolders } + 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, ) @@ -333,7 +338,7 @@ private fun TvSearchContent( is TvSearchState.Results -> TvSearchResults( results = searchState, - filter = filter, + filter = effectiveFilter, searchTerm = query.trim(), onOpenPodcast = onOpenPodcast, onOpenFolder = onOpenFolder, @@ -521,6 +526,21 @@ private fun TvSearchResults( restoreFocusTrigger = restoreFocusTrigger, ) } + + TvSearchFilter.Folders -> 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, + ) + } } } 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 1b1367a04d5..d9d8b0995ca 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 @@ -306,6 +306,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 { From 21b70f7db8d22865b613bba5c432166e059313fb Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Tue, 25 Aug 2026 14:57:21 +0200 Subject: [PATCH 7/8] Address PR comments --- .../pocketcasts/search/TvSearchScreen.kt | 4 ++-- .../pocketcasts/search/TvSearchViewModel.kt | 7 ++++++- .../search/TvSearchViewModelTest.kt | 20 +++++++++++++++++-- 3 files changed, 26 insertions(+), 5 deletions(-) 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 cf5750e1887..04cac417678 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 @@ -291,8 +291,8 @@ private fun TvSearchContent( Spacer(modifier = Modifier.height(24.dp)) } - val hasFolders = (searchState as? TvSearchState.Results)?.folders?.isNotEmpty() == true - val filters = TvSearchFilter.entries.filter { it != TvSearchFilter.Folders || hasFolders } + val hasFolders = (searchState as? TvSearchState.Results)?.let { it.folders.isNotEmpty() || it.isPartial } == true + val filters = remember(hasFolders) { TvSearchFilter.entries.filter { it != TvSearchFilter.Folders || hasFolders } } val effectiveFilter = if (filter in filters) filter else TvSearchFilter.TopResults if (searchState !is TvSearchState.Idle) { 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 d9d8b0995ca..f2fdf5ce42e 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 @@ -14,6 +14,7 @@ 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 @@ -170,6 +171,9 @@ class TvSearchViewModel @Inject constructor( val episodes = remoteResults.filterIsInstance() .distinctBy(ImprovedSearchResultItem.EpisodeItem::uuid) val folders = foldersSearch.await() + if (folders.isEmpty() && _filter.value == TvSearchFilter.Folders) { + _filter.value = TvSearchFilter.TopResults + } if (podcasts.isEmpty() && episodes.isEmpty() && folders.isEmpty()) { TvSearchState.NoResults } else { @@ -194,7 +198,8 @@ class TvSearchViewModel @Inject constructor( } return folderManager.getAll() .filter { it.name.contains(term, ignoreCase = true) } - .map { folder -> FolderItem.Folder(folder = folder, podcasts = podcastManager.findPodcastsInFolder(folder.uuid)) } + .sortedBy { PodcastsSortType.cleanStringForSort(it.name) } + .map { folder -> FolderItem.Folder(folder = folder, podcasts = folderManager.findFolderPodcastsSorted(folder.uuid)) } } suspend fun folderPodcasts(folderUuid: String): List { 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 e005d1f0e72..58be71cdbb6 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 @@ -462,7 +462,7 @@ class TvSearchViewModelTest { whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) whenever(syncManager.isLoggedIn()).thenReturn(true) whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar Shows"), folderEntity("Comedy"))) - whenever { podcastManager.findPodcastsInFolder(any()) }.thenReturn(emptyList()) + whenever { folderManager.findFolderPodcastsSorted(any()) }.thenReturn(emptyList()) val viewModel = createViewModel() viewModel.onQueryChange("sugar") @@ -472,6 +472,22 @@ class TvSearchViewModelTest { 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()) @@ -492,7 +508,7 @@ class TvSearchViewModelTest { whenever { improvedSearchManager.combinedSearch(any()) }.thenReturn(emptyList()) whenever(syncManager.isLoggedIn()).thenReturn(true) whenever { folderManager.getAll() }.thenReturn(listOf(folderEntity("Sugar"))) - whenever { podcastManager.findPodcastsInFolder(any()) }.thenReturn(emptyList()) + whenever { folderManager.findFolderPodcastsSorted(any()) }.thenReturn(emptyList()) val viewModel = createViewModel() viewModel.onQueryChange("sugar") From 023bbdfb72d764c11e81a09c37e60ac0c5f3e605 Mon Sep 17 00:00:00 2001 From: Tamas Szelezsan Date: Wed, 26 Aug 2026 16:20:02 +0200 Subject: [PATCH 8/8] Address PR comments --- .../pocketcasts/search/TvSearchScreen.kt | 46 +++++++++++------ .../pocketcasts/search/TvSearchViewModel.kt | 16 ++++-- .../search/TvSearchViewModelTest.kt | 50 +++++++++++++++++++ 3 files changed, 94 insertions(+), 18 deletions(-) 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 04cac417678..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 @@ -106,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() @@ -138,6 +139,7 @@ fun TvSearchScreen( query = query, searchState = searchState, filter = filter, + hasFolderResults = hasFolderResults, categories = categories, discoverRows = discoverRows, onQueryChange = viewModel::onQueryChange, @@ -238,6 +240,7 @@ private fun TvSearchContent( query: String, searchState: TvSearchState, filter: TvSearchFilter, + hasFolderResults: Boolean, categories: List, discoverRows: List, onQueryChange: (String) -> Unit, @@ -291,8 +294,7 @@ private fun TvSearchContent( Spacer(modifier = Modifier.height(24.dp)) } - val hasFolders = (searchState as? TvSearchState.Results)?.let { it.folders.isNotEmpty() || it.isPartial } == true - val filters = remember(hasFolders) { TvSearchFilter.entries.filter { it != TvSearchFilter.Folders || hasFolders } } + 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) { @@ -527,19 +529,30 @@ private fun TvSearchResults( ) } - TvSearchFilter.Folders -> 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, - ) + 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, + ) + } } } } @@ -818,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 = ""), @@ -845,6 +859,7 @@ private fun TvSearchIdleWithHistoryPreview() { query = "", searchState = TvSearchState.Idle, filter = TvSearchFilter.TopResults, + hasFolderResults = false, categories = emptyList(), discoverRows = emptyList(), onQueryChange = {}, @@ -904,6 +919,7 @@ private fun TvSearchResultsPreview() { ), ), filter = TvSearchFilter.TopResults, + hasFolderResults = true, categories = emptyList(), discoverRows = emptyList(), onQueryChange = {}, 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 f2fdf5ce42e..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 @@ -68,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() @@ -127,6 +130,7 @@ class TvSearchViewModel @Inject constructor( if (term.isEmpty()) { _suggestions.value = emptyList() _searchState.value = TvSearchState.Idle + updateFolderResults(hasFolders = false) return } searchJob = viewModelScope.launch { @@ -171,9 +175,7 @@ class TvSearchViewModel @Inject constructor( val episodes = remoteResults.filterIsInstance() .distinctBy(ImprovedSearchResultItem.EpisodeItem::uuid) val folders = foldersSearch.await() - if (folders.isEmpty() && _filter.value == TvSearchFilter.Folders) { - _filter.value = TvSearchFilter.TopResults - } + updateFolderResults(hasFolders = folders.isNotEmpty()) if (podcasts.isEmpty() && episodes.isEmpty() && folders.isEmpty()) { TvSearchState.NoResults } else { @@ -183,6 +185,7 @@ class TvSearchViewModel @Inject constructor( throw exception } catch (exception: Exception) { Timber.e(exception, "Failed to search on TV") + updateFolderResults(hasFolders = false) TvSearchState.Error } } @@ -192,6 +195,13 @@ 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() 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 58be71cdbb6..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 @@ -37,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 @@ -533,6 +534,55 @@ class TvSearchViewModelTest { 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,