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,