Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -78,9 +82,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<SearchOpenedFolder?, String>(
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,
Expand All @@ -97,11 +109,14 @@ fun TvSearchScreen(

var openedPodcastUuid by rememberSaveable { mutableStateOf<String?>(null) }
var openedCategory by rememberSaveable(stateSaver = TvOpenedCategorySaver) { mutableStateOf<TvOpenedCategory?>(null) }
var openedFolder by rememberSaveable(stateSaver = SearchOpenedFolderSaver) { mutableStateOf<SearchOpenedFolder?>(null) }
var detailsEpisode by remember { mutableStateOf<PodcastEpisode?>(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
Expand All @@ -123,6 +138,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,
Expand All @@ -138,8 +154,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 },
Expand All @@ -158,7 +189,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,
Expand Down Expand Up @@ -199,6 +236,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,
Expand Down Expand Up @@ -288,6 +326,7 @@ private fun TvSearchContent(
filter = filter,
searchTerm = query.trim(),
onOpenPodcast = onOpenPodcast,
onOpenFolder = onOpenFolder,
onPlayEpisode = onPlayEpisode,
onOpenEpisodeActions = onOpenEpisodeActions,
restoreFocusTrigger = restoreFocusTrigger,
Expand Down Expand Up @@ -410,6 +449,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,
Expand All @@ -418,7 +458,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,
Expand Down Expand Up @@ -475,7 +517,9 @@ private fun TvSearchResults(
private fun TvSearchTopResults(
podcasts: List<ImprovedSearchResultItem.PodcastItem>,
episodes: List<ImprovedSearchResultItem.EpisodeItem>,
folders: List<FolderItem.Folder>,
onOpenPodcast: (String) -> Unit,
onOpenFolder: (FolderItem.Folder) -> Unit,
onPlayEpisode: (ImprovedSearchResultItem.EpisodeItem) -> Unit,
onOpenEpisodeActions: (ImprovedSearchResultItem.EpisodeItem) -> Unit,
restoreFocusTrigger: Int,
Expand All @@ -492,10 +536,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)) }
Expand All @@ -522,6 +568,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(
Expand All @@ -534,6 +588,28 @@ private fun TvSearchTopResults(
}
}

private fun LazyListScope.tvSearchFoldersRow(
folders: List<FolderItem.Folder>,
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) },
)
Comment on lines +604 to +608

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The folder card has no width here, so this row will not lay out correctly.

Every other tile used inside TvRow carries an explicit width, because a LazyRow item nested in a LazyColumn item is measured with unbounded constraints on both axes:

  • TvPodcastTileimageModifier = Modifier.width(TvPodcastTileDefaults.RowImageWidth) (TvSearchScreen.kt:711)
  • TvSearchEpisodeCardModifier.width(SearchEpisodeCardWidth)
  • TvVideoTile.width(323.dp), TvFeaturedTile.width(642.dp)

TvFolderCard has no intrinsic size — it derives everything from BoxWithConstraints:

BoxWithConstraints(Modifier.fillMaxWidth().aspectRatio(1f)) {
    val cardWidth = maxWidth
    val coverSize = cardWidth * COVER_SIZE_RATIO
    …
}

With unbounded constraints fillMaxWidth() is a no-op, aspectRatio(1f) finds no finite constraint to anchor to and passes the original constraints through, so maxWidth == Dp.Infinity. Every derived value (coverSize, coverSpacing, the padding(top = …), Arrangement.spacedBy(coverSpacing)) becomes Dp.Infinity and the card degenerates. In TvYourPodcastsGrid this never surfaces because the grid passes a sizing itemModifier (TvYourPodcastsScreen.kt:181).

Suggested fix — give it the same footprint as the podcast tiles in the row:

Suggested change
TvFolderCard(
folder = folderItem.folder,
coverUrls = folderItem.podcasts.take(FOLDER_COVER_COUNT).map { PodcastImage.getMediumArtworkUrl(it.uuid) },
onClick = { onOpenFolder(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),
)

Worth adding a @Preview with a TvSearchState.Results containing folders — the existing TvSearchScreenPreview only renders Idle, so nothing currently exercises this row.

}
}
}

@Composable
private fun TvSearchEpisodeCarousel(
title: String,
Expand Down Expand Up @@ -718,6 +794,7 @@ private fun TvSearchScreenPreview() {
onQueryChange = {},
onFilterSelect = {},
onOpenPodcast = {},
onOpenFolder = {},
onOpenCategory = {},
onPlayEpisode = {},
onOpenEpisodeActions = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,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
Expand Down Expand Up @@ -46,6 +49,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<List<DiscoverCategory>>(emptyList())
Expand Down Expand Up @@ -129,6 +134,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 {
Expand All @@ -153,10 +159,11 @@ class TvSearchViewModel @Inject constructor(
.map { if (it.uuid in localUuids) it.copy(isFollowed = true) else it }
val episodes = remoteResults.filterIsInstance<ImprovedSearchResultItem.EpisodeItem>()
.distinctBy(ImprovedSearchResultItem.EpisodeItem::uuid)
if (podcasts.isEmpty() && episodes.isEmpty()) {
val folders = foldersSearch.await()
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
Expand All @@ -171,6 +178,19 @@ class TvSearchViewModel @Inject constructor(
_filter.value = filter
}

private suspend fun searchFolders(term: String): List<FolderItem.Folder> {
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)) }
}
Comment on lines +181 to +188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subscribing to getSignInState() per search can stall the whole result set, and has side effects.

UserManager.getSignInState() is a cold chain that, on every subscription (UserManager.kt:111-145):

  1. fires notificationScheduler.setupTrendingAndRecommendationsNotifications() when logged in, or cancelScheduledWorksByTag(...) when logged out — so every debounced search re-triggers WorkManager scheduling;
  2. calls analyticsController.refreshMetadata() on each emission;
  3. when settings.cachedSubscription is empty, falls into fetchSubscriptionForSignIn(), which is a network call retried SUBSCRIPTION_FETCH_MAX_ATTEMPTS = 3 × SUBSCRIPTION_FETCH_TIMEOUT_MS = 10_000.

Because the terminal state assignment awaits it (val folders = foldersSearch.await() on line 162), case 3 means the final Results — including all the remote episodes — can be withheld for up to ~30s on a cold subscription cache. runCatching doesn't help here; it's a hang, not a throw. That contradicts the "never delays the remote results" intent in the PR description.

The phone avoids this by subscribing once and seeding a value (SearchHandler.kt:67):

private val signInStateObservable = userManager.getSignInState().startWith(SignInState.SignedOut).toObservable()

Suggest mirroring that — hoist the sign-in state into a StateFlow on the ViewModel (userManager.getSignInState().asFlow().stateIn(viewModelScope, SharingStarted.Eagerly, SignInState.SignedOut)) and read .value in searchFolders, so the gate is a cheap in-memory check.

Two smaller notes on this block:

  • runCatching { searchFolders(term) } (line 137) also swallows CancellationException, turning a cancelled child into a "successful" emptyList(). Harmless today since the parent is being cancelled anyway, but it's inconsistent with the explicit catch (exception: CancellationException) { throw exception } pattern used everywhere else in this file. A catch (e: Exception) after re-throwing cancellation would match the house style.
  • The cover artwork uses podcastManager.findPodcastsInFolder (raw DB order) while the detail screen uses folderManager.findFolderPodcastsSorted. This matches the phone, so it's fine for parity, but the four covers on a card can differ from what the same folder shows in Your Podcasts.


suspend fun folderPodcasts(folderUuid: String): List<Podcast> {
return folderManager.findFolderPodcastsSorted(folderUuid)
}

fun saveSearchTerm(term: String) {
val trimmed = term.trim()
if (trimmed.isEmpty()) {
Expand Down Expand Up @@ -286,6 +306,7 @@ sealed interface TvSearchState {
data class Results(
val podcasts: List<ImprovedSearchResultItem.PodcastItem>,
val episodes: List<ImprovedSearchResultItem.EpisodeItem>,
val folders: List<FolderItem.Folder> = emptyList(),
val isPartial: Boolean = false,
) : TvSearchState
}
Loading