diff --git a/modules/services/images/src/main/res/drawable-nodpi/tv_banner_create_account.webp b/modules/services/images/src/main/res/drawable-nodpi/tv_banner_create_account.webp new file mode 100644 index 00000000000..99cebdd8c8f Binary files /dev/null and b/modules/services/images/src/main/res/drawable-nodpi/tv_banner_create_account.webp differ diff --git a/modules/services/images/src/main/res/drawable-nodpi/tv_banner_discover_more.webp b/modules/services/images/src/main/res/drawable-nodpi/tv_banner_discover_more.webp new file mode 100644 index 00000000000..3092f4bc5e3 Binary files /dev/null and b/modules/services/images/src/main/res/drawable-nodpi/tv_banner_discover_more.webp differ diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index 4eb22dd7744..b8c9fbd35e5 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -253,6 +253,14 @@ Because you liked Podcast Keep Listening Browse categories + Pocket Casts recommends + One account. Every screen + Your follows and progress exactly where you left them + Create a free account + You haven\'t seen the half of it + Your next obsession is in here somewhere + Discover more shows + Searching… No results Try more general or different keywords. Check your connection and try again. diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvBannerRow.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvBannerRow.kt new file mode 100644 index 00000000000..b0e29e15124 --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvBannerRow.kt @@ -0,0 +1,214 @@ +package au.com.shiftyjelly.pocketcasts.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverBanner +import au.com.shiftyjelly.pocketcasts.theme.TvTheme +import au.com.shiftyjelly.pocketcasts.theme.tvColors +import au.com.shiftyjelly.pocketcasts.theme.tvTypography +import au.com.shiftyjelly.pocketcasts.images.R as IR +import au.com.shiftyjelly.pocketcasts.localization.R as LR + +@Composable +fun TvBannerRow( + banner: TvDiscoverBanner, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + TvTile( + onClick = onClick, + shape = CardDefaults.shape(RoundedCornerShape(12.dp)), + scale = CardDefaults.scale(focusedScale = 1.05f), + colors = CardDefaults.colors( + containerColor = Color.Black, + focusedContainerColor = Color.Black, + ), + interactionSource = interactionSource, + modifier = modifier + .fillMaxWidth() + .height(132.dp), + ) { + Box(modifier = Modifier.fillMaxSize().clipToBounds()) { + BackgroundLift() + Image( + painter = painterResource(banner.artwork()), + contentDescription = null, + contentScale = ContentScale.FillHeight, + alignment = Alignment.CenterEnd, + modifier = Modifier + .align(Alignment.CenterEnd) + .requiredHeight(banner.artworkHeight), + ) + if (banner.hasArtworkMask) { + // Opaque black over the text side so the bright collage only shows on the end edge. + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + 0f to Color.Black, + 0.82f to Color.Black, + 1f to Color.Transparent, + ), + ), + ) + BackgroundLift() + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(48.dp), + modifier = Modifier + .align(Alignment.CenterStart) + .fillMaxHeight() + .fillMaxWidth(banner.contentWidthFraction) + .padding(horizontal = 48.dp), + ) { + BannerActionPill(banner, isFocused) + BannerText(banner, modifier = Modifier.weight(1f, fill = false)) + } + } + } +} + +@Composable +private fun BackgroundLift() { + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + 0f to MaterialTheme.tvColors.backgroundActive20, + 0.55f to Color.Transparent, + ), + ), + ) +} + +@Composable +private fun BannerText(banner: TvDiscoverBanner, modifier: Modifier = Modifier) { + Column(verticalArrangement = Arrangement.Center, modifier = modifier) { + Text( + text = banner.title(), + style = MaterialTheme.tvTypography.callout, + color = MaterialTheme.tvColors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = banner.subtitle(), + style = MaterialTheme.tvTypography.body, + color = MaterialTheme.tvColors.textSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun BannerActionPill(banner: TvDiscoverBanner, isFocused: Boolean) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .background(if (isFocused) MaterialTheme.tvColors.backgroundActive else MaterialTheme.tvColors.backgroundActive20) + .padding(horizontal = 24.dp, vertical = 12.dp), + ) { + Text( + text = banner.actionTitle(), + style = MaterialTheme.tvTypography.caption1, + color = if (isFocused) MaterialTheme.tvColors.textPrimaryActive else MaterialTheme.tvColors.backgroundActive, + ) + } +} + +@DrawableRes +private fun TvDiscoverBanner.artwork(): Int = when (this) { + TvDiscoverBanner.CreateAccount -> IR.drawable.tv_banner_create_account + TvDiscoverBanner.DiscoverMore -> IR.drawable.tv_banner_discover_more +} + +private val TvDiscoverBanner.artworkHeight: Dp + get() = when (this) { + TvDiscoverBanner.CreateAccount -> 150.dp + TvDiscoverBanner.DiscoverMore -> 170.dp + } + +private val TvDiscoverBanner.contentWidthFraction: Float + get() = when (this) { + TvDiscoverBanner.CreateAccount -> 0.68f + TvDiscoverBanner.DiscoverMore -> 0.82f + } + +private val TvDiscoverBanner.hasArtworkMask: Boolean + get() = this == TvDiscoverBanner.DiscoverMore + +@Composable +private fun TvDiscoverBanner.title(): String = when (this) { + TvDiscoverBanner.CreateAccount -> stringResource(LR.string.tv_banner_create_account_title) + TvDiscoverBanner.DiscoverMore -> stringResource(LR.string.tv_banner_discover_more_title) +} + +@Composable +private fun TvDiscoverBanner.subtitle(): String = when (this) { + TvDiscoverBanner.CreateAccount -> stringResource(LR.string.tv_banner_create_account_subtitle) + TvDiscoverBanner.DiscoverMore -> stringResource(LR.string.tv_banner_discover_more_subtitle) +} + +@Composable +private fun TvDiscoverBanner.actionTitle(): String = when (this) { + TvDiscoverBanner.CreateAccount -> stringResource(LR.string.tv_banner_create_account_action_title) + TvDiscoverBanner.DiscoverMore -> stringResource(LR.string.tv_banner_discover_more_action_title) +} + +@Preview(device = Devices.TV_1080p) +@Composable +private fun TvBannerRowPreview() { + TvTheme { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .background(MaterialTheme.tvColors.backgroundSunken) + .padding(48.dp), + ) { + TvBannerRow(banner = TvDiscoverBanner.CreateAccount, onClick = {}) + TvBannerRow(banner = TvDiscoverBanner.DiscoverMore, onClick = {}) + } + } +} diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvPodcastTile.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvPodcastTile.kt index 7373e3f7150..609f5514861 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvPodcastTile.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvPodcastTile.kt @@ -2,18 +2,25 @@ package au.com.shiftyjelly.pocketcasts.component import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text import au.com.shiftyjelly.pocketcasts.theme.TvTheme import au.com.shiftyjelly.pocketcasts.theme.tvColors +import au.com.shiftyjelly.pocketcasts.theme.tvTypography import coil3.compose.AsyncImage +import au.com.shiftyjelly.pocketcasts.localization.R as LR @Composable fun TvPodcastTile( @@ -22,17 +29,37 @@ fun TvPodcastTile( onClick: () -> Unit, modifier: Modifier = Modifier, imageModifier: Modifier = Modifier, + isSponsored: Boolean = false, ) { TvTile( onClick = onClick, modifier = modifier, ) { - AsyncImage( - model = artworkUrl, - contentDescription = podcastTitle, - contentScale = ContentScale.Crop, - modifier = imageModifier.aspectRatio(1f), - ) + if (isSponsored) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + AsyncImage( + model = artworkUrl, + contentDescription = podcastTitle, + contentScale = ContentScale.Crop, + modifier = imageModifier + .padding(horizontal = 18.dp, vertical = 9.dp) + .aspectRatio(1f), + ) + Text( + text = stringResource(LR.string.sponsored), + style = MaterialTheme.tvTypography.caption2, + color = MaterialTheme.tvColors.textSecondary, + modifier = Modifier.padding(bottom = 9.dp), + ) + } + } else { + AsyncImage( + model = artworkUrl, + contentDescription = podcastTitle, + contentScale = ContentScale.Crop, + modifier = imageModifier.aspectRatio(1f), + ) + } } } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvCategoryPodcastsScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvCategoryPodcastsScreen.kt new file mode 100644 index 00000000000..6e36be0e692 --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvCategoryPodcastsScreen.kt @@ -0,0 +1,176 @@ +package au.com.shiftyjelly.pocketcasts.discover + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.tv.material3.MaterialTheme +import au.com.shiftyjelly.pocketcasts.component.TvEmptyState +import au.com.shiftyjelly.pocketcasts.component.TvPodcastGridScaffold +import au.com.shiftyjelly.pocketcasts.component.TvPodcastTile +import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView +import au.com.shiftyjelly.pocketcasts.theme.TvTheme +import au.com.shiftyjelly.pocketcasts.theme.tvColors +import kotlinx.coroutines.CancellationException +import au.com.shiftyjelly.pocketcasts.localization.R as LR + +@Composable +fun TvCategoryPodcastsScreen( + categoryName: String, + categorySource: String, + getCategoryPodcasts: suspend (String) -> List, + onOpenPodcast: (String) -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, + restoreFocusTrigger: Int = 0, +) { + var reloadTrigger by remember(categorySource) { mutableIntStateOf(0) } + var uiState by remember(categorySource) { mutableStateOf(TvCategoryPodcastsUiState.Loading) } + val currentGetCategoryPodcasts by rememberUpdatedState(getCategoryPodcasts) + LaunchedEffect(categorySource, reloadTrigger) { + uiState = TvCategoryPodcastsUiState.Loading + uiState = runCatching { currentGetCategoryPodcasts(categorySource) } + .fold( + onSuccess = { podcasts -> + if (podcasts.isEmpty()) TvCategoryPodcastsUiState.Empty else TvCategoryPodcastsUiState.Loaded(podcasts) + }, + onFailure = { exception -> + if (exception is CancellationException) throw exception + TvCategoryPodcastsUiState.Error + }, + ) + } + + TvCategoryPodcastsContent( + categoryName = categoryName, + uiState = uiState, + onOpenPodcast = onOpenPodcast, + onClose = onClose, + onRetry = { reloadTrigger++ }, + modifier = modifier, + restoreFocusTrigger = restoreFocusTrigger, + ) +} + +@Composable +private fun TvCategoryPodcastsContent( + categoryName: String, + uiState: TvCategoryPodcastsUiState, + onOpenPodcast: (String) -> Unit, + onClose: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, + restoreFocusTrigger: Int = 0, +) { + AnimatedContent( + targetState = uiState, + transitionSpec = { fadeIn(tween(durationMillis = 300)) togetherWith fadeOut(tween(durationMillis = 300)) }, + contentKey = { state -> + when (state) { + is TvCategoryPodcastsUiState.Loading -> "loading" + is TvCategoryPodcastsUiState.Empty -> "empty" + is TvCategoryPodcastsUiState.Error -> "error" + is TvCategoryPodcastsUiState.Loaded -> "content" + } + }, + label = "TvCategoryPodcastsContent", + modifier = modifier, + ) { state -> + when (state) { + is TvCategoryPodcastsUiState.Loading -> LoadingView( + color = MaterialTheme.tvColors.textPrimary, + modifier = Modifier.fillMaxSize(), + ) + + is TvCategoryPodcastsUiState.Empty -> TvEmptyState( + title = stringResource(LR.string.tv_search_no_results_title), + subtitle = stringResource(LR.string.tv_search_no_results_subtitle), + actionLabel = stringResource(LR.string.ok), + onAction = onClose, + autoFocusAction = true, + modifier = Modifier.fillMaxSize(), + ) + + is TvCategoryPodcastsUiState.Error -> TvEmptyState( + title = stringResource(LR.string.error_generic_message), + subtitle = stringResource(LR.string.tv_search_no_results_subtitle), + actionLabel = stringResource(LR.string.retry), + onAction = onRetry, + autoFocusAction = true, + modifier = Modifier.fillMaxSize(), + ) + + is TvCategoryPodcastsUiState.Loaded -> TvPodcastGridScaffold( + title = stringResource(LR.string.discover_most_popular_in, categoryName), + itemKeys = state.podcasts.map(TvDiscoverPodcast::uuid), + autoFocusFirstItem = true, + restoreFocusTrigger = restoreFocusTrigger, + modifier = Modifier.fillMaxSize(), + ) { index, itemModifier -> + val podcast = state.podcasts[index] + TvPodcastTile( + artworkUrl = podcast.artworkUrl, + podcastTitle = podcast.title, + onClick = { onOpenPodcast(podcast.uuid) }, + imageModifier = Modifier.fillMaxWidth(), + modifier = itemModifier, + isSponsored = podcast.isSponsored, + ) + } + } + } +} + +private sealed interface TvCategoryPodcastsUiState { + data object Loading : TvCategoryPodcastsUiState + + data object Empty : TvCategoryPodcastsUiState + + data object Error : TvCategoryPodcastsUiState + + data class Loaded( + val podcasts: List, + ) : TvCategoryPodcastsUiState +} + +@Preview(device = Devices.TV_1080p) +@Composable +private fun TvCategoryPodcastsPreview() { + TvTheme { + Box(modifier = Modifier.background(MaterialTheme.tvColors.backgroundSunken)) { + TvCategoryPodcastsContent( + categoryName = "True Crime", + uiState = TvCategoryPodcastsUiState.Loaded( + podcasts = (1..6).map { index -> + TvDiscoverPodcast( + uuid = "podcast-$index", + title = "Podcast $index", + author = "Author $index", + description = "Description $index", + ) + }, + ), + onOpenPodcast = {}, + onClose = {}, + onRetry = {}, + ) + } + } +} diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt index 94b246c9f9b..9a0e66a6a4d 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt @@ -7,6 +7,7 @@ import au.com.shiftyjelly.pocketcasts.servers.model.Discover import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverEpisode import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverPodcast +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverRegion import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverRow import au.com.shiftyjelly.pocketcasts.servers.model.DisplayStyle import au.com.shiftyjelly.pocketcasts.servers.model.ListType @@ -18,6 +19,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import timber.log.Timber +import au.com.shiftyjelly.pocketcasts.localization.R as LR class TvDiscoverFeedLoader @Inject constructor( private val listRepository: ListRepository, @@ -25,7 +27,7 @@ class TvDiscoverFeedLoader @Inject constructor( @ApplicationContext private val context: Context, ) { suspend fun load(isLoggedIn: Boolean): List { - return buildRows(listRepository.getHomeDiscoverFeed(isLoggedIn), isLoggedIn) + return buildRows(listRepository.getHomeDiscoverFeed(isLoggedIn), isLoggedIn, includeHomeSections = true) } suspend fun searchDiscoverFeed(): Discover = listRepository.getSearchDiscoverFeed() @@ -33,8 +35,9 @@ class TvDiscoverFeedLoader @Inject constructor( suspend fun loadCategories(discover: Discover): List { val source = discover.layout.firstOrNull { it.type is ListType.Categories } ?.source?.takeIf(String::isNotBlank) ?: return emptyList() + val replacements = regionReplacements(discover) return try { - listRepository.getCategoriesList(source) + listRepository.getCategoriesList(source).map { it.resolveSource(replacements) } } catch (exception: CancellationException) { throw exception } catch (exception: Exception) { @@ -43,52 +46,124 @@ class TvDiscoverFeedLoader @Inject constructor( } } - suspend fun buildRows(discover: Discover, isLoggedIn: Boolean): List = coroutineScope { - val region = discover.regions[settings.discoverCountryCode.value] - ?: discover.regions[discover.defaultRegionCode] - ?: error("Could not resolve discover region") - val replacements = mapOf( - discover.regionCodeToken to region.code, - discover.regionNameToken to region.name, - ) + suspend fun loadCategoryPodcasts(source: String, categoryId: Int, isLoggedIn: Boolean): List = coroutineScope { + val podcastsDeferred = async { + val feed = checkNotNull(listRepository.getListFeed(source)) { "Failed to load category feed $source" } + feed.podcasts.orEmpty() + .distinctBy(DiscoverPodcast::uuid) + .map { it.toTvDiscoverPodcast(isSponsored = false) } + } + val sponsoredDeferred = async { loadCategorySponsoredPodcasts(categoryId, isLoggedIn) } + mergeCategorySponsored(podcastsDeferred.await(), sponsoredDeferred.await()) + } + + private suspend fun loadCategorySponsoredPodcasts(categoryId: Int, isLoggedIn: Boolean): List = coroutineScope { + val discover = try { + listRepository.getHomeDiscoverFeed(isLoggedIn) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load TV category sponsored ads") + return@coroutineScope emptyList() + } + val region = resolveRegionOrNull(discover) ?: return@coroutineScope emptyList() + val replacements = regionReplacements(discover, region) + discover.layout + .transformWithRegion(region, replacements, context.resources) + .filter { it.categoryId == categoryId && it.sponsored && it.source.isNotBlank() && (isLoggedIn || it.authenticated != true) } + .map { row -> async { loadSponsoredPodcasts(row) } } + .awaitAll() + .flatten() + } + + private fun mergeCategorySponsored(podcasts: List, sponsored: List): List { + if (sponsored.isEmpty()) return podcasts + val sponsoredUuids = sponsored.mapTo(mutableSetOf(), TvDiscoverPodcast::uuid) + val rest = podcasts.filterNot { it.uuid in sponsoredUuids } + val position = minOf(SPONSORED_CATEGORY_POSITION, rest.size) + return rest.toMutableList().apply { addAll(position, sponsored) } + } + + suspend fun buildRows(discover: Discover, isLoggedIn: Boolean, includeHomeSections: Boolean = false): List = coroutineScope { + val region = resolveRegion(discover) + val replacements = regionReplacements(discover, region) discover.layout .transformWithRegion(region, replacements, context.resources) .filter { it.categoryId == null } // Rows with a category ID are sponsored ads for the category pages. .filter { isLoggedIn || it.authenticated != true } - .map { row -> async { loadRow(row) } } + .map { row -> async { loadRow(row, includeHomeSections, replacements) } } .awaitAll() .filterNotNull() // Dedup after loading so a duplicate id whose feed came back empty falls back to a populated one. .distinctBy(TvDiscoverRow::id) } - private suspend fun loadRow(row: DiscoverRow): TvDiscoverRow? { - if (row.source.isBlank()) { - Timber.d("Dropping discover row without a source: ${row.rowId()}") - return null + private suspend fun loadRow(row: DiscoverRow, includeHomeSections: Boolean, replacements: Map): TvDiscoverRow? { + val bannerType = row.type + if (bannerType is ListType.Unknown && bannerType.value == BANNER_TYPE) { + if (!includeHomeSections) return null + val banner = TvDiscoverBanner.fromId(row.id) ?: return null + return TvDiscoverRow.Banner(id = banner.id, title = row.title, banner = banner) } return when (row.type) { - is ListType.PodcastList -> loadPodcastsRow(row) - is ListType.EpisodeList -> loadEpisodesRow(row) - is ListType.Categories, is ListType.Unknown -> null + is ListType.PodcastList -> if (row.source.isBlank()) null else loadPodcastsRow(row) + is ListType.EpisodeList -> if (row.source.isBlank()) null else loadEpisodesRow(row) + is ListType.Categories -> if (includeHomeSections) loadCategoriesRow(row, replacements) else null + is ListType.Unknown -> null } } - private suspend fun loadPodcastsRow(row: DiscoverRow): TvDiscoverRow? { - val feed = listRepository.getListFeed(row.source, row.authenticated) ?: return null - val podcasts = feed.podcasts.orEmpty() + private suspend fun loadPodcastsRow(row: DiscoverRow): TvDiscoverRow? = coroutineScope { + val feedDeferred = async { listRepository.getListFeed(row.source, row.authenticated) } + val insertionsDeferred = async { loadSponsoredInsertions(row) } + val feed = feedDeferred.await() ?: return@coroutineScope null + val basePodcasts = feed.podcasts.orEmpty() .distinctBy(DiscoverPodcast::uuid) .map { it.toTvDiscoverPodcast(isSponsored = row.sponsored) } - if (podcasts.isEmpty()) return null - val title = feed.title?.takeIf { it.isNotBlank() } ?: row.title - return when (row.displayStyle) { - is DisplayStyle.Carousel -> TvDiscoverRow.FeaturedPodcasts(id = row.rowId(), title = title, podcasts = podcasts) - is DisplayStyle.SinglePodcast -> TvDiscoverRow.SinglePodcast(id = row.rowId(), title = title, podcasts = podcasts) - else -> TvDiscoverRow.Podcasts(id = row.rowId(), title = title, podcasts = podcasts) + val podcasts = insertSponsored(basePodcasts, insertionsDeferred.await()) + if (podcasts.isEmpty()) return@coroutineScope null + val feedTitle = feed.title?.takeIf { it.isNotBlank() } ?: row.title + when (row.displayStyle) { + is DisplayStyle.Carousel -> TvDiscoverRow.FeaturedPodcasts(id = row.rowId(), title = feedTitle, podcasts = podcasts) + + is DisplayStyle.SinglePodcast -> { + val title = if (row.sponsored) context.getString(LR.string.tv_sponsored_podcast_section_title) else feedTitle + TvDiscoverRow.SinglePodcast(id = row.rowId(), title = title, podcasts = podcasts) + } + + else -> TvDiscoverRow.Podcasts(id = row.rowId(), title = feedTitle, podcasts = podcasts) } } + private suspend fun loadSponsoredInsertions(row: DiscoverRow): Map = coroutineScope { + row.sponsoredPodcasts + .mapNotNull { sponsored -> + val source = sponsored.source?.takeIf(String::isNotBlank) ?: return@mapNotNull null + val position = sponsored.position ?: return@mapNotNull null + async { listRepository.getListFeed(source, row.authenticated)?.podcasts?.firstOrNull()?.let { position to it } } + } + .awaitAll() + .filterNotNull() + .associate { (position, podcast) -> position to podcast.toTvDiscoverPodcast(isSponsored = true) } + } + + private suspend fun loadSponsoredPodcasts(row: DiscoverRow): List { + return listRepository.getListFeed(row.source, row.authenticated)?.podcasts.orEmpty() + .distinctBy(DiscoverPodcast::uuid) + .map { it.toTvDiscoverPodcast(isSponsored = true) } + } + + private fun insertSponsored(podcasts: List, insertions: Map): List { + if (insertions.isEmpty()) return podcasts + val insertedUuids = insertions.values.mapTo(mutableSetOf(), TvDiscoverPodcast::uuid) + val result = podcasts.filterNotTo(mutableListOf()) { it.uuid in insertedUuids } + insertions.toSortedMap().forEach { (position, podcast) -> + result.add(position.coerceIn(0, result.size), podcast) + } + return result + } + private suspend fun loadEpisodesRow(row: DiscoverRow): TvDiscoverRow? { val feed = listRepository.getListFeed(row.source, row.authenticated) ?: return null val episodes = feed.episodes.orEmpty() @@ -106,6 +181,45 @@ class TvDiscoverFeedLoader @Inject constructor( return TvDiscoverRow.Episodes(id = row.rowId(), title = title, episodes = episodes) } + private suspend fun loadCategoriesRow(row: DiscoverRow, replacements: Map): TvDiscoverRow? { + if (row.source.isBlank()) return null + val categories = try { + listRepository.getCategoriesList(row.source).map { it.resolveSource(replacements) } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + Timber.e(exception, "Failed to load TV discover categories") + return null + } + if (categories.isEmpty()) return null + return TvDiscoverRow.Categories( + id = row.rowId(), + title = context.getString(LR.string.tv_search_browse_categories), + categories = categories, + ) + } + + private fun resolveRegionOrNull(discover: Discover): DiscoverRegion? { + return discover.regions[settings.discoverCountryCode.value] + ?: discover.regions[discover.defaultRegionCode] + } + + private fun resolveRegion(discover: Discover): DiscoverRegion { + return resolveRegionOrNull(discover) ?: error("Could not resolve discover region") + } + + private fun regionReplacements(discover: Discover, region: DiscoverRegion? = resolveRegionOrNull(discover)): Map { + if (region == null) return emptyMap() + return mapOf( + discover.regionCodeToken to region.code, + discover.regionNameToken to region.name, + ) + } + + private fun DiscoverCategory.resolveSource(replacements: Map): DiscoverCategory { + return transformWithReplacements(replacements, context.resources) as? DiscoverCategory ?: this + } + private fun DiscoverRow.rowId() = listUuid ?: id ?: title private fun DiscoverPodcast.toTvDiscoverPodcast(isSponsored: Boolean) = TvDiscoverPodcast( @@ -115,4 +229,9 @@ class TvDiscoverFeedLoader @Inject constructor( description = description.orEmpty(), isSponsored = isSponsored, ) + + companion object { + private const val BANNER_TYPE = "banner" + private const val SPONSORED_CATEGORY_POSITION = 5 + } } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt index e8ea58e46ab..8f58e5ec41d 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt @@ -1,6 +1,7 @@ package au.com.shiftyjelly.pocketcasts.discover import au.com.shiftyjelly.pocketcasts.repositories.images.PodcastImage +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory sealed interface TvDiscoverRow { val id: String @@ -12,6 +13,18 @@ sealed interface TvDiscoverRow { val podcasts: List, ) : TvDiscoverRow + data class Categories( + override val id: String, + override val title: String, + val categories: List, + ) : TvDiscoverRow + + data class Banner( + override val id: String, + override val title: String, + val banner: TvDiscoverBanner, + ) : TvDiscoverRow + data class SinglePodcast( override val id: String, override val title: String, @@ -31,6 +44,16 @@ sealed interface TvDiscoverRow { ) : TvDiscoverRow } +enum class TvDiscoverBanner(val id: String) { + CreateAccount("create_account"), + DiscoverMore("discover_more"), + ; + + companion object { + fun fromId(id: String?): TvDiscoverBanner? = entries.firstOrNull { it.id == id } + } +} + data class TvDiscoverPodcast( val uuid: String, val title: String, diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt index 9d8747ecdf1..34ac4d0e5c9 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt @@ -1,17 +1,22 @@ package au.com.shiftyjelly.pocketcasts.discover import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.unit.dp +import au.com.shiftyjelly.pocketcasts.component.TvBannerRow +import au.com.shiftyjelly.pocketcasts.component.TvCategoryTile import au.com.shiftyjelly.pocketcasts.component.TvFeaturedTile import au.com.shiftyjelly.pocketcasts.component.TvPodcastTile import au.com.shiftyjelly.pocketcasts.component.TvPodcastTileDefaults import au.com.shiftyjelly.pocketcasts.component.TvRow import au.com.shiftyjelly.pocketcasts.component.TvSinglePodcastTile import au.com.shiftyjelly.pocketcasts.component.TvVideoTile +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory fun LazyListScope.tvDiscoverRow( row: TvDiscoverRow, @@ -20,6 +25,8 @@ fun LazyListScope.tvDiscoverRow( modifier: Modifier = Modifier, focusRequester: FocusRequester? = null, contentPadding: PaddingValues = PaddingValues(horizontal = 32.dp), + onOpenCategory: (DiscoverCategory) -> Unit = {}, + onTapBanner: (TvDiscoverBanner) -> Unit = {}, ) { when (row) { is TvDiscoverRow.FeaturedPodcasts -> item(key = row.id) { @@ -99,8 +106,34 @@ fun LazyListScope.tvDiscoverRow( podcastTitle = podcast.title, onClick = { onOpenPodcast(podcast.uuid) }, imageModifier = Modifier.width(TvPodcastTileDefaults.RowImageWidth), + isSponsored = podcast.isSponsored, + ) + } + } + + is TvDiscoverRow.Categories -> item(key = row.id) { + TvRow( + title = row.title, + items = row.categories, + contentPadding = contentPadding, + key = { it.id }, + focusRequester = focusRequester, + modifier = modifier, + ) { category -> + TvCategoryTile( + category = category, + onClick = { onOpenCategory(category) }, ) } } + + is TvDiscoverRow.Banner -> item(key = row.id) { + val bannerModifier = if (focusRequester != null) modifier.focusRequester(focusRequester) else modifier + TvBannerRow( + banner = row.banner, + onClick = { onTapBanner(row.banner) }, + modifier = bannerModifier.padding(contentPadding), + ) + } } } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvOpenedCategory.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvOpenedCategory.kt new file mode 100644 index 00000000000..12b7cba8c0e --- /dev/null +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvOpenedCategory.kt @@ -0,0 +1,14 @@ +package au.com.shiftyjelly.pocketcasts.discover + +import androidx.compose.runtime.saveable.listSaver + +data class TvOpenedCategory(val id: Int, val name: String, val source: String) + +val TvOpenedCategorySaver = listSaver( + save = { category -> category?.let { listOf(it.id.toString(), it.name, it.source) }.orEmpty() }, + restore = { saved -> + saved.takeIf { it.size == 3 }?.let { (id, name, source) -> + id.toIntOrNull()?.let { TvOpenedCategory(it, name, source) } + } + }, +) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt index 59a4d1eaa0f..10853829d01 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeScreen.kt @@ -35,11 +35,16 @@ import au.com.shiftyjelly.pocketcasts.component.LocalTvToastHostState import au.com.shiftyjelly.pocketcasts.component.TvDetailOverlay import au.com.shiftyjelly.pocketcasts.component.tvFocusInactiveWhen import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView +import au.com.shiftyjelly.pocketcasts.discover.TvCategoryPodcastsScreen +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverBanner import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverEpisode import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverPodcast 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.podcasts.TvPodcastDetailsScreen +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import au.com.shiftyjelly.pocketcasts.theme.TvTheme import au.com.shiftyjelly.pocketcasts.theme.TvTopBarHeight import au.com.shiftyjelly.pocketcasts.theme.tvColors @@ -48,13 +53,18 @@ import au.com.shiftyjelly.pocketcasts.localization.R as LR @Composable fun TvHomeScreen( + onNavigateToSearch: () -> Unit, + onCreateAccount: () -> Unit, modifier: Modifier = Modifier, viewModel: TvHomeViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var openedCategory by rememberSaveable(stateSaver = TvOpenedCategorySaver) { mutableStateOf(null) } var openedPodcastUuid by rememberSaveable { mutableStateOf(null) } var restoreFocusTrigger by remember { mutableIntStateOf(0) } + var categoryRestoreTrigger by remember { mutableIntStateOf(0) } + val category = openedCategory val podcastUuid = openedPodcastUuid val openNowPlaying = LocalOpenNowPlaying.current val toastHostState = LocalTvToastHostState.current @@ -75,16 +85,38 @@ fun TvHomeScreen( onRetry = viewModel::load, onOpenPodcast = { openedPodcastUuid = it }, onPlayEpisode = viewModel::playEpisode, + onOpenCategory = { openedCategory = TvOpenedCategory(it.id, it.name, it.source) }, + onTapBanner = { banner -> + when (banner) { + TvDiscoverBanner.DiscoverMore -> onNavigateToSearch() + TvDiscoverBanner.CreateAccount -> onCreateAccount() + } + }, modifier = Modifier .fillMaxSize() .padding(top = TvTopBarHeight) - .tvFocusInactiveWhen(podcastUuid != null), + .tvFocusInactiveWhen(category != null || podcastUuid != null), restoreFocusTrigger = restoreFocusTrigger, ) + TvDetailOverlay( + target = category, + onBack = { openedCategory = null }, + modifier = Modifier.tvFocusInactiveWhen(podcastUuid != null), + onHide = { restoreFocusTrigger++ }, + ) { openCategory -> + TvCategoryPodcastsScreen( + categoryName = openCategory.name, + categorySource = openCategory.source, + getCategoryPodcasts = { source -> viewModel.categoryPodcasts(openCategory.id, source) }, + onOpenPodcast = { openedPodcastUuid = it }, + onClose = { openedCategory = null }, + restoreFocusTrigger = categoryRestoreTrigger, + ) + } TvDetailOverlay( target = podcastUuid, onBack = { openedPodcastUuid = null }, - onHide = { restoreFocusTrigger++ }, + onHide = { if (openedCategory != null) categoryRestoreTrigger++ else restoreFocusTrigger++ }, ) { uuid -> TvPodcastDetailsScreen( podcastUuid = uuid, @@ -101,6 +133,8 @@ private fun TvHomeContent( onOpenPodcast: (String) -> Unit, onPlayEpisode: (TvDiscoverEpisode) -> Unit, modifier: Modifier = Modifier, + onOpenCategory: (DiscoverCategory) -> Unit = {}, + onTapBanner: (TvDiscoverBanner) -> Unit = {}, restoreFocusTrigger: Int = 0, ) { when (uiState) { @@ -115,6 +149,8 @@ private fun TvHomeContent( rows = uiState.rows, onOpenPodcast = onOpenPodcast, onPlayEpisode = onPlayEpisode, + onOpenCategory = onOpenCategory, + onTapBanner = onTapBanner, modifier = modifier, restoreFocusTrigger = restoreFocusTrigger, ) @@ -151,6 +187,8 @@ private fun TvHomeRows( onOpenPodcast: (String) -> Unit, onPlayEpisode: (TvDiscoverEpisode) -> Unit, modifier: Modifier = Modifier, + onOpenCategory: (DiscoverCategory) -> Unit = {}, + onTapBanner: (TvDiscoverBanner) -> Unit = {}, restoreFocusTrigger: Int = 0, ) { var lastFocusedRowIndex by rememberSaveable(rows.size) { mutableIntStateOf(0) } @@ -185,6 +223,8 @@ private fun TvHomeRows( onPlayEpisode = onPlayEpisode, modifier = rowModifier, focusRequester = rowFocusRequester, + onOpenCategory = onOpenCategory, + onTapBanner = onTapBanner, ) } @@ -200,11 +240,23 @@ private fun TvHomeContentPreview() { TvHomeContent( uiState = TvHomeUiState.Ready( rows = listOf( + TvDiscoverRow.Banner( + id = "discover_more", + title = "", + banner = TvDiscoverBanner.DiscoverMore, + ), TvDiscoverRow.FeaturedPodcasts( id = "featured", title = "Featured", podcasts = (1..3).map { previewPodcast(it) }, ), + TvDiscoverRow.Categories( + id = "categories", + title = "Browse categories", + categories = (1..4).map { index -> + DiscoverCategory(id = index, name = "Category $index", icon = "", source = "") + }, + ), TvDiscoverRow.Episodes( id = "tv_featured_videos", title = "Made for TV", diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModel.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModel.kt index 5da7a487f52..da53cbf689f 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.analytics.SourceView import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverEpisode import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverPodcast import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow import au.com.shiftyjelly.pocketcasts.models.db.dao.PodcastDao import au.com.shiftyjelly.pocketcasts.models.db.dao.UpNextDao @@ -159,6 +160,10 @@ class TvHomeViewModel @Inject constructor( } } + suspend fun categoryPodcasts(categoryId: Int, source: String): List { + return discoverFeedLoader.loadCategoryPodcasts(source, categoryId, syncManager.isLoggedIn()) + } + fun playEpisode(episode: TvDiscoverEpisode) { viewModelScope.launch { try { diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt index 8d564e1776c..93186286967 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/home/TvScaffold.kt @@ -89,7 +89,10 @@ fun TvScaffold( // content so their overlays can fill the full height. val belowTopBar = Modifier.fillMaxSize().padding(top = TvTopBarHeight) when (tab) { - is TvTab.Home -> TvHomeScreen() + is TvTab.Home -> TvHomeScreen( + onNavigateToSearch = { viewModel.selectTab(TvTab.Search) }, + onCreateAccount = onCreateAccount, + ) is TvTab.YourPodcasts -> TvYourPodcastsScreen( onNavigateToHome = navigateToHome, diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt index 974cbeaa56b..76860d82f39 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/home/TvHomeViewModelTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import app.cash.turbine.test import au.com.shiftyjelly.pocketcasts.analytics.SourceView +import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverBanner import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverEpisode import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow @@ -22,6 +23,7 @@ import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager import au.com.shiftyjelly.pocketcasts.servers.model.Discover +import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverEpisode import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverPodcast import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverRegion @@ -30,6 +32,7 @@ import au.com.shiftyjelly.pocketcasts.servers.model.DisplayStyle 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.servers.model.SponsoredPodcast import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule import com.jakewharton.rxrelay2.BehaviorRelay import io.reactivex.Single @@ -78,6 +81,8 @@ class TvHomeViewModelTest { whenever(it.getString(LR.string.tv_home_keep_listening)).thenReturn("Keep Listening") whenever(it.getString(LR.string.up_next)).thenReturn("Up Next") whenever(it.getString(LR.string.filters_title_new_releases)).thenReturn("New Releases") + whenever(it.getString(LR.string.tv_search_browse_categories)).thenReturn("Browse categories") + whenever(it.getString(LR.string.tv_sponsored_podcast_section_title)).thenReturn("Pocket Casts recommends") } private val discoverCountryCode = mock> { whenever(it.value).thenReturn("us") @@ -303,6 +308,265 @@ class TvHomeViewModelTest { verify(listRepository, never()).getListFeed(eq("https://lists/category-ad.json"), any()) } + @Test + fun `banner rows are parsed from the feed even without a source`() = runTest { + whenever(syncManager.isLoggedIn()).thenReturn(false) + whenever(listRepository.getHomeDiscoverFeed(isLoggedIn = false)).thenReturn( + discover( + bannerRow(id = "create_account"), + bannerRow(id = "discover_more"), + row(id = "trending", title = "Row Trending", source = "https://lists/trending.json"), + ), + ) + whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) + .thenReturn(podcastFeed("podcast-trending")) + + val viewModel = createViewModel() + + viewModel.uiState.test { + val state = awaitItem() as TvHomeUiState.Ready + assertEquals(listOf("create_account", "discover_more", "trending"), state.rows.map { it.id }) + assertEquals(TvDiscoverBanner.CreateAccount, (state.rows[0] as TvDiscoverRow.Banner).banner) + assertEquals(TvDiscoverBanner.DiscoverMore, (state.rows[1] as TvDiscoverRow.Banner).banner) + } + } + + @Test + fun `banner rows with an unknown id are dropped`() = runTest { + whenever(syncManager.isLoggedIn()).thenReturn(false) + whenever(listRepository.getHomeDiscoverFeed(isLoggedIn = false)).thenReturn( + discover( + bannerRow(id = "mystery_banner"), + row(id = "trending", title = "Row Trending", source = "https://lists/trending.json"), + ), + ) + whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) + .thenReturn(podcastFeed("podcast-trending")) + + val viewModel = createViewModel() + + viewModel.uiState.test { + val state = awaitItem() as TvHomeUiState.Ready + assertEquals(listOf("trending"), state.rows.map { it.id }) + } + } + + @Test + fun `category rows load their pills with region resolved sources`() = runTest { + whenever(syncManager.isLoggedIn()).thenReturn(false) + whenever(listRepository.getHomeDiscoverFeed(isLoggedIn = false)).thenReturn( + discover( + row( + id = "categories", + title = "Row Categories", + source = "https://lists/categories.json", + type = ListType.Categories, + displayStyle = DisplayStyle.Pills(), + ), + row(id = "trending", title = "Row Trending", source = "https://lists/trending.json"), + ), + ) + whenever(listRepository.getCategoriesList("https://lists/categories.json")).thenReturn( + listOf( + DiscoverCategory(id = 1, name = "Comedy", icon = "", source = "https://category/[regionCode].json"), + DiscoverCategory(id = 2, name = "True Crime", icon = "", source = "https://category/true-crime.json"), + ), + ) + whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) + .thenReturn(podcastFeed("podcast-trending")) + + val viewModel = createViewModel() + + viewModel.uiState.test { + val state = awaitItem() as TvHomeUiState.Ready + assertEquals(listOf("categories", "trending"), state.rows.map { it.id }) + val categoriesRow = state.rows[0] as TvDiscoverRow.Categories + assertEquals("Browse categories", categoriesRow.title) + assertEquals(listOf("Comedy", "True Crime"), categoriesRow.categories.map { it.name }) + assertEquals("https://category/us.json", categoriesRow.categories[0].source) + } + } + + @Test + fun `category rows are dropped when their pills fail to load`() = runTest { + whenever(syncManager.isLoggedIn()).thenReturn(false) + whenever(listRepository.getHomeDiscoverFeed(isLoggedIn = false)).thenReturn( + discover( + row( + id = "categories", + title = "Row Categories", + source = "https://lists/categories.json", + type = ListType.Categories, + displayStyle = DisplayStyle.Pills(), + ), + row(id = "trending", title = "Row Trending", source = "https://lists/trending.json"), + ), + ) + whenever(listRepository.getCategoriesList("https://lists/categories.json")) + .thenThrow(RuntimeException("boom")) + whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) + .thenReturn(podcastFeed("podcast-trending")) + + val viewModel = createViewModel() + + viewModel.uiState.test { + val state = awaitItem() as TvHomeUiState.Ready + assertEquals(listOf("trending"), state.rows.map { it.id }) + } + } + + @Test + fun `categoryPodcasts maps the list feed podcasts`() = runTest { + whenever(listRepository.getHomeDiscoverFeed(isLoggedIn = false)).thenReturn(discover()) + whenever(listRepository.getListFeed(eq("https://category/us.json"), any())) + .thenReturn(podcastFeed("podcast-1", "podcast-2")) + + val podcasts = createViewModel().categoryPodcasts(categoryId = 7, source = "https://category/us.json") + + assertEquals(listOf("podcast-1", "podcast-2"), podcasts.map { it.uuid }) + } + + @Test + fun `categoryPodcasts throws when the feed cannot be loaded`() = runTest { + whenever(listRepository.getHomeDiscoverFeed(isLoggedIn = false)).thenReturn(discover()) + whenever(listRepository.getListFeed(eq("https://category/us.json"), any())).thenReturn(null) + + val result = runCatching { createViewModel().categoryPodcasts(categoryId = 7, source = "https://category/us.json") } + + assertTrue(result.exceptionOrNull() is IllegalStateException) + } + + @Test + fun `categoryPodcasts merges sponsored ads at a fixed position`() = runTest { + whenever(listRepository.getHomeDiscoverFeed(isLoggedIn = false)).thenReturn( + discover( + row( + id = "category-ad", + title = "Row Category Ad", + source = "https://lists/category-ad.json", + sponsored = true, + categoryId = 7, + ), + ), + ) + whenever(listRepository.getListFeed(eq("https://category/us.json"), any())) + .thenReturn(podcastFeed("p-0", "p-1", "p-2", "p-3", "p-4", "p-5", "p-6")) + whenever(listRepository.getListFeed(eq("https://lists/category-ad.json"), any())) + .thenReturn(podcastFeed("ad-1")) + + val podcasts = createViewModel().categoryPodcasts(categoryId = 7, source = "https://category/us.json") + + assertEquals("ad-1", podcasts[5].uuid) + assertTrue(podcasts[5].isSponsored) + assertEquals(8, podcasts.size) + } + + @Test + fun `categoryPodcasts appends the sponsored ad when the category is short`() = runTest { + whenever(listRepository.getHomeDiscoverFeed(isLoggedIn = false)).thenReturn( + discover( + row( + id = "category-ad", + title = "Row Category Ad", + source = "https://lists/category-ad.json", + sponsored = true, + categoryId = 7, + ), + ), + ) + whenever(listRepository.getListFeed(eq("https://category/us.json"), any())) + .thenReturn(podcastFeed("p-0", "p-1")) + whenever(listRepository.getListFeed(eq("https://lists/category-ad.json"), any())) + .thenReturn(podcastFeed("ad-1")) + + val podcasts = createViewModel().categoryPodcasts(categoryId = 7, source = "https://category/us.json") + + assertEquals(listOf("p-0", "p-1", "ad-1"), podcasts.map { it.uuid }) + assertTrue(podcasts.last().isSponsored) + } + + @Test + fun `sponsored single-podcast row uses the recommends title`() = runTest { + whenever(syncManager.isLoggedIn()).thenReturn(false) + whenever(listRepository.getHomeDiscoverFeed(isLoggedIn = false)).thenReturn( + discover( + row( + id = "sponsored-id", + title = "Row Sponsored", + source = "https://lists/sponsored.json", + displayStyle = DisplayStyle.SinglePodcast(), + sponsored = true, + ), + ), + ) + whenever(listRepository.getListFeed(eq("https://lists/sponsored.json"), any())) + .thenReturn(podcastFeed("podcast-sponsored")) + + val viewModel = createViewModel() + + viewModel.uiState.test { + val state = awaitItem() as TvHomeUiState.Ready + assertEquals("Pocket Casts recommends", state.rows.single().title) + } + } + + @Test + fun `sponsored podcasts are injected at their feed positions`() = runTest { + whenever(syncManager.isLoggedIn()).thenReturn(false) + whenever(listRepository.getHomeDiscoverFeed(isLoggedIn = false)).thenReturn( + discover( + row( + id = "trending", + title = "Row Trending", + source = "https://lists/trending.json", + sponsoredPodcasts = listOf(SponsoredPodcast(position = 1, source = "https://lists/ad.json")), + ), + ), + ) + whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) + .thenReturn(podcastFeed("p-0", "p-1", "p-2")) + whenever(listRepository.getListFeed(eq("https://lists/ad.json"), any())) + .thenReturn(podcastFeed("ad-1")) + + val viewModel = createViewModel() + + viewModel.uiState.test { + val state = awaitItem() as TvHomeUiState.Ready + val row = state.rows.single() as TvDiscoverRow.Podcasts + assertEquals(listOf("p-0", "ad-1", "p-1", "p-2"), row.podcasts.map { it.uuid }) + assertTrue(row.podcasts[1].isSponsored) + } + } + + @Test + fun `sponsored podcast already present in the row is not duplicated`() = runTest { + whenever(syncManager.isLoggedIn()).thenReturn(false) + whenever(listRepository.getHomeDiscoverFeed(isLoggedIn = false)).thenReturn( + discover( + row( + id = "trending", + title = "Row Trending", + source = "https://lists/trending.json", + sponsoredPodcasts = listOf(SponsoredPodcast(position = 0, source = "https://lists/ad.json")), + ), + ), + ) + whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) + .thenReturn(podcastFeed("p-0", "ad-1", "p-2")) + whenever(listRepository.getListFeed(eq("https://lists/ad.json"), any())) + .thenReturn(podcastFeed("ad-1")) + + val viewModel = createViewModel() + + viewModel.uiState.test { + val state = awaitItem() as TvHomeUiState.Ready + val row = state.rows.single() as TvDiscoverRow.Podcasts + assertEquals(listOf("ad-1", "p-0", "p-2"), row.podcasts.map { it.uuid }) + assertEquals(1, row.podcasts.count { it.uuid == "ad-1" }) + assertTrue(row.podcasts.first().isSponsored) + } + } + @Test fun `rows with duplicate ids are deduplicated`() = runTest { whenever(syncManager.isLoggedIn()).thenReturn(false) @@ -625,6 +889,7 @@ class TvHomeViewModelTest { authenticated: Boolean = false, regions: List = listOf("us"), categoryId: Int? = null, + sponsoredPodcasts: List = emptyList(), ) = DiscoverRow( id = id, type = type, @@ -639,10 +904,19 @@ class TvHomeViewModelTest { curated = curated, sponsored = sponsored, authenticated = authenticated, + sponsoredPodcasts = sponsoredPodcasts, mostPopularCategoriesId = null, sponsoredCategoryIds = null, ) + private fun bannerRow(id: String) = row( + id = id, + title = "", + source = "", + type = ListType.Unknown("banner"), + displayStyle = DisplayStyle.Unknown("inline_banner"), + ) + private fun podcastFeed(vararg podcastUuids: String, title: String? = null) = listFeed( title = title, podcasts = podcastUuids.map { uuid -> 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 22639aeeefb..ef50ab1ee53 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 @@ -122,6 +122,22 @@ class TvSearchViewModelTest { assertTrue(rows.single() is TvDiscoverRow.Podcasts) } + @Test + fun `banner rows are excluded from the search discover feed`() = runTest { + whenever(listRepository.getSearchDiscoverFeed()).thenReturn( + discover( + bannerRow(id = "create_account"), + row(id = "trending", title = "Trending", source = "https://lists/trending.json"), + ), + ) + whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any())) + .thenReturn(podcastFeed("podcast-trending")) + + val viewModel = createViewModel() + + assertEquals(listOf("trending"), viewModel.discoverRows.value.map { it.id }) + } + @Test fun `authenticated rows are dropped when logged out`() = runTest { whenever(listRepository.getSearchDiscoverFeed()).thenReturn( @@ -403,6 +419,14 @@ class TvSearchViewModelTest { type = ListType.Categories, ) + private fun bannerRow(id: String) = row( + id = id, + title = "", + source = "", + type = ListType.Unknown("banner"), + displayStyle = DisplayStyle.Unknown("inline_banner"), + ) + private fun podcastFeed(vararg podcastUuids: String) = ListFeed( title = null, subtitle = null,