diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml
index c967939408d..203e447f250 100644
--- a/modules/services/localization/src/main/res/values/strings.xml
+++ b/modules/services/localization/src/main/res/values/strings.xml
@@ -252,6 +252,11 @@
Recommendations
Because you liked Podcast
Keep Listening
+ On-screen keyboard
+ Browse categories
+ Space
+ 123
+ ABC
Starred Episodes
Log in to Pocket Casts
Scan the QR code or go to %1$s
diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt
index f5687eb59ea..305ae7a8e81 100644
--- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt
+++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/lists/ListRepository.kt
@@ -18,6 +18,18 @@ class ListRepository(
return listWebService.getDiscoverFeed(platform = platform, version = 3)
}
+ suspend fun getSearchDiscoverFeed(): Discover {
+ return listWebService.getSearchDiscoverFeed(platform = platform, version = 3)
+ }
+
+ suspend fun getLoggedInDiscoverFeed(): Discover {
+ return listWebService.getLoggedInDiscoverFeed(platform = platform, version = 3)
+ }
+
+ suspend fun getLoggedOutDiscoverFeed(): Discover {
+ return listWebService.getLoggedOutDiscoverFeed(platform = platform, version = 3)
+ }
+
suspend fun getListFeed(url: String, authenticated: Boolean? = false): ListFeed? {
return runCatching {
if (authenticated == true) {
diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt
index 7a81acf21c6..0cdfd095838 100644
--- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt
+++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModel.kt
@@ -60,10 +60,11 @@ data class DiscoverRow(
@Json(name = "id") val id: String?,
@Json(name = "type") override val type: ListType,
@Json(name = "summary_style") override val displayStyle: DisplayStyle,
- @Json(name = "expanded_style") override val expandedStyle: ExpandedStyle,
+ @Json(name = "expanded_style") override val expandedStyle: ExpandedStyle = ExpandedStyle.PlainList(),
@Json(name = "expanded_top_item_label") override val expandedTopItemLabel: String?,
@Json(name = "title") override val title: String,
- @Json(name = "source") override val source: String,
+ // Blank when the feed omits the key: a placeholder row the client fills locally. Consumers must skip blank sources.
+ @Json(name = "source") override val source: String = "",
@Json(name = "uuid") override val listUuid: String?,
@Json(name = "category_id") val categoryId: Int?,
@Json(name = "regions") val regions: List,
diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt
index da535466e5f..a788d5e4e5d 100644
--- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt
+++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt
@@ -12,6 +12,15 @@ interface ListWebService {
@GET("/discover/{platform}/content_v{version}.json")
suspend fun getDiscoverFeed(@Path("platform") platform: String, @Path("version") version: Int): Discover
+ @GET("/discover/{platform}/content_v{version}_search.json")
+ suspend fun getSearchDiscoverFeed(@Path("platform") platform: String, @Path("version") version: Int): Discover
+
+ @GET("/discover/{platform}/content_v{version}_logged_in.json")
+ suspend fun getLoggedInDiscoverFeed(@Path("platform") platform: String, @Path("version") version: Int): Discover
+
+ @GET("/discover/{platform}/content_v{version}_logged_out.json")
+ suspend fun getLoggedOutDiscoverFeed(@Path("platform") platform: String, @Path("version") version: Int): Discover
+
@GET
suspend fun getListFeed(@Url url: String): ListFeed
diff --git a/modules/services/servers/src/test/kotlin/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModelTest.kt b/modules/services/servers/src/test/kotlin/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModelTest.kt
new file mode 100644
index 00000000000..6e4eb9e0f82
--- /dev/null
+++ b/modules/services/servers/src/test/kotlin/au/com/shiftyjelly/pocketcasts/servers/model/DiscoverModelTest.kt
@@ -0,0 +1,33 @@
+package au.com.shiftyjelly.pocketcasts.servers.model
+
+import com.squareup.moshi.Moshi
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class DiscoverModelTest {
+
+ private val adapter = Moshi.Builder()
+ .add(ListTypeMoshiAdapter())
+ .add(DisplayStyleMoshiAdapter())
+ .add(ExpandedStyleMoshiAdapter())
+ .build()
+ .adapter(DiscoverRow::class.java)
+
+ @Test
+ fun `discover row defaults source and expanded style when the keys are absent`() {
+ val row = adapter.fromJson(
+ """
+ {
+ "type": "podcast_list",
+ "summary_style": "small_list",
+ "title": "Trending",
+ "regions": ["us"]
+ }
+ """.trimIndent(),
+ )
+
+ assertEquals("", row?.source)
+ assertTrue(row?.expandedStyle is ExpandedStyle.PlainList)
+ }
+}
diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt
new file mode 100644
index 00000000000..da7086259e8
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvCategoryTile.kt
@@ -0,0 +1,93 @@
+package au.com.shiftyjelly.pocketcasts.component
+
+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.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+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.graphics.ColorFilter
+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.tv.material3.CardDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory
+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
+
+@Composable
+fun TvCategoryTile(
+ category: DiscoverCategory,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ val isFocused by interactionSource.collectIsFocusedAsState()
+ val contentColor = if (isFocused) MaterialTheme.tvColors.textPrimary else MaterialTheme.tvColors.textSecondary
+
+ TvTile(
+ onClick = onClick,
+ colors = CardDefaults.colors(
+ containerColor = MaterialTheme.tvColors.backgroundOverlay,
+ focusedContainerColor = MaterialTheme.tvColors.backgroundOverlay,
+ ),
+ interactionSource = interactionSource,
+ modifier = modifier
+ .width(280.dp)
+ .height(128.dp),
+ ) {
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ AsyncImage(
+ model = category.icon,
+ contentDescription = null,
+ colorFilter = ColorFilter.tint(contentColor),
+ modifier = Modifier.size(28.dp),
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ Text(
+ text = category.name,
+ style = MaterialTheme.tvTypography.body,
+ color = contentColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(horizontal = 12.dp),
+ )
+ }
+ }
+}
+
+@Preview(device = Devices.TV_1080p)
+@Composable
+private fun TvCategoryTilePreview() {
+ TvTheme {
+ Column(
+ modifier = Modifier
+ .background(MaterialTheme.tvColors.backgroundSunken)
+ .padding(48.dp),
+ ) {
+ TvCategoryTile(
+ category = DiscoverCategory(id = 1, name = "Comedy", icon = "", source = ""),
+ onClick = {},
+ )
+ }
+ }
+}
diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSinglePodcastTile.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSinglePodcastTile.kt
new file mode 100644
index 00000000000..b6deeef54c0
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvSinglePodcastTile.kt
@@ -0,0 +1,168 @@
+package au.com.shiftyjelly.pocketcasts.component
+
+import androidx.compose.foundation.background
+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.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.layout.ContentScale
+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.tv.material3.CardDefaults
+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 TvSinglePodcastTile(
+ artworkUrl: String,
+ title: String,
+ author: String,
+ description: String,
+ isSponsored: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ var isFocused by remember { mutableStateOf(false) }
+ val textPrimary = if (isFocused) MaterialTheme.tvColors.textPrimaryActive else MaterialTheme.tvColors.textPrimary
+ val textSecondary = if (isFocused) MaterialTheme.tvColors.textSecondaryActive else MaterialTheme.tvColors.textSecondary
+
+ TvTile(
+ onClick = onClick,
+ scale = CardDefaults.scale(focusedScale = 1.05f),
+ shape = CardDefaults.shape(shape = RoundedCornerShape(12.dp)),
+ colors = CardDefaults.colors(
+ containerColor = MaterialTheme.tvColors.backgroundSunken,
+ focusedContainerColor = MaterialTheme.tvColors.backgroundActive,
+ ),
+ modifier = modifier.onFocusChanged { isFocused = it.isFocused },
+ ) {
+ Row(
+ modifier = Modifier
+ .width(642.dp)
+ .height(200.dp)
+ .padding(24.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AsyncImage(
+ model = artworkUrl,
+ contentDescription = title,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(152.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ )
+
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 24.dp),
+ ) {
+ if (isSponsored || author.isNotBlank()) {
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ if (isSponsored) {
+ Text(
+ text = stringResource(LR.string.sponsored),
+ style = MaterialTheme.tvTypography.body,
+ color = textPrimary,
+ )
+ if (author.isNotBlank()) {
+ Text(
+ text = "·",
+ style = MaterialTheme.tvTypography.body,
+ color = textSecondary,
+ )
+ }
+ }
+ if (author.isNotBlank()) {
+ Text(
+ text = author,
+ style = MaterialTheme.tvTypography.body,
+ color = textSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+
+ Text(
+ text = title,
+ style = MaterialTheme.tvTypography.title2,
+ color = textPrimary,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+
+ if (description.isNotBlank()) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = description,
+ style = MaterialTheme.tvTypography.body,
+ color = textSecondary,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview(device = Devices.TV_1080p)
+@Composable
+private fun TvSinglePodcastTilePreview() {
+ TvTheme {
+ Box(modifier = Modifier.background(MaterialTheme.tvColors.backgroundSunken).padding(24.dp)) {
+ TvSinglePodcastTile(
+ artworkUrl = "",
+ title = "The Writer's Voice",
+ author = "iHeartPodcasts and Kaleidoscope",
+ description = "New fiction from the pages of The New Yorker, read by its authors.",
+ isSponsored = true,
+ onClick = {},
+ )
+ }
+ }
+}
+
+@Preview(device = Devices.TV_1080p)
+@Composable
+private fun TvSinglePodcastTileRecommendedPreview() {
+ TvTheme {
+ Box(modifier = Modifier.background(MaterialTheme.tvColors.backgroundSunken).padding(24.dp)) {
+ TvSinglePodcastTile(
+ artworkUrl = "",
+ title = "The Writer's Voice",
+ author = "",
+ description = "New fiction from the pages of The New Yorker, read by its authors.",
+ isSponsored = false,
+ onClick = {},
+ )
+ }
+ }
+}
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
new file mode 100644
index 00000000000..9532c93d1d9
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverFeedLoader.kt
@@ -0,0 +1,116 @@
+package au.com.shiftyjelly.pocketcasts.discover
+
+import android.content.Context
+import au.com.shiftyjelly.pocketcasts.preferences.Settings
+import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository
+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.DiscoverRow
+import au.com.shiftyjelly.pocketcasts.servers.model.DisplayStyle
+import au.com.shiftyjelly.pocketcasts.servers.model.ListType
+import au.com.shiftyjelly.pocketcasts.servers.model.transformWithRegion
+import dagger.hilt.android.qualifiers.ApplicationContext
+import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import timber.log.Timber
+
+class TvDiscoverFeedLoader @Inject constructor(
+ private val listRepository: ListRepository,
+ private val settings: Settings,
+ @ApplicationContext private val context: Context,
+) {
+ suspend fun load(isLoggedIn: Boolean): List {
+ val feed = if (isLoggedIn) listRepository.getLoggedInDiscoverFeed() else listRepository.getLoggedOutDiscoverFeed()
+ return buildRows(feed, isLoggedIn)
+ }
+
+ suspend fun searchDiscoverFeed(): Discover = listRepository.getSearchDiscoverFeed()
+
+ suspend fun loadCategories(discover: Discover): List {
+ val source = discover.layout.firstOrNull { it.type is ListType.Categories }
+ ?.source?.takeIf(String::isNotBlank) ?: return emptyList()
+ return try {
+ listRepository.getCategoriesList(source)
+ } catch (exception: CancellationException) {
+ throw exception
+ } catch (exception: Exception) {
+ Timber.e(exception, "Failed to load TV search categories")
+ emptyList()
+ }
+ }
+
+ 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,
+ )
+
+ 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) } }
+ .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()) return null
+ return when (row.type) {
+ is ListType.PodcastList -> loadPodcastsRow(row)
+ is ListType.EpisodeList -> loadEpisodesRow(row)
+ is ListType.Categories, 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()
+ .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)
+ }
+ }
+
+ private suspend fun loadEpisodesRow(row: DiscoverRow): TvDiscoverRow? {
+ val feed = listRepository.getListFeed(row.source, row.authenticated) ?: return null
+ val episodes = feed.episodes.orEmpty()
+ .distinctBy(DiscoverEpisode::uuid)
+ .map { episode ->
+ TvDiscoverEpisode(
+ episodeUuid = episode.uuid,
+ episodeTitle = episode.title.orEmpty(),
+ podcastUuid = episode.podcast_uuid,
+ podcastTitle = episode.podcast_title.orEmpty(),
+ )
+ }
+ if (episodes.isEmpty()) return null
+ val title = feed.title?.takeIf { it.isNotBlank() } ?: row.title
+ return TvDiscoverRow.Episodes(id = row.rowId(), title = title, episodes = episodes)
+ }
+
+ private fun DiscoverRow.rowId() = listUuid ?: id ?: title
+
+ private fun DiscoverPodcast.toTvDiscoverPodcast(isSponsored: Boolean) = TvDiscoverPodcast(
+ uuid = uuid,
+ title = title.orEmpty(),
+ author = author.orEmpty(),
+ description = description.orEmpty(),
+ isSponsored = isSponsored,
+ )
+}
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
new file mode 100644
index 00000000000..e8ea58e46ab
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverModels.kt
@@ -0,0 +1,52 @@
+package au.com.shiftyjelly.pocketcasts.discover
+
+import au.com.shiftyjelly.pocketcasts.repositories.images.PodcastImage
+
+sealed interface TvDiscoverRow {
+ val id: String
+ val title: String
+
+ data class FeaturedPodcasts(
+ override val id: String,
+ override val title: String,
+ val podcasts: List,
+ ) : TvDiscoverRow
+
+ data class SinglePodcast(
+ override val id: String,
+ override val title: String,
+ val podcasts: List,
+ ) : TvDiscoverRow
+
+ data class Podcasts(
+ override val id: String,
+ override val title: String,
+ val podcasts: List,
+ ) : TvDiscoverRow
+
+ data class Episodes(
+ override val id: String,
+ override val title: String,
+ val episodes: List,
+ ) : TvDiscoverRow
+}
+
+data class TvDiscoverPodcast(
+ val uuid: String,
+ val title: String,
+ val author: String,
+ val description: String,
+ val isSponsored: Boolean = false,
+) {
+ val artworkUrl: String = PodcastImage.getMediumArtworkUrl(uuid)
+}
+
+data class TvDiscoverEpisode(
+ val episodeUuid: String,
+ val episodeTitle: String,
+ val podcastUuid: String,
+ val podcastTitle: String,
+) {
+ val thumbnailUrl: String = PodcastImage.getArtworkUrl(size = 960, uuid = podcastUuid, isWearOS = false)
+ val podcastArtworkUrl: String = PodcastImage.getArtworkUrl(size = 200, uuid = podcastUuid, isWearOS = false)
+}
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
new file mode 100644
index 00000000000..9d8747ecdf1
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/discover/TvDiscoverRows.kt
@@ -0,0 +1,106 @@
+package au.com.shiftyjelly.pocketcasts.discover
+
+import androidx.compose.foundation.layout.PaddingValues
+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.unit.dp
+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
+
+fun LazyListScope.tvDiscoverRow(
+ row: TvDiscoverRow,
+ onOpenPodcast: (String) -> Unit,
+ onPlayEpisode: (TvDiscoverEpisode) -> Unit,
+ modifier: Modifier = Modifier,
+ focusRequester: FocusRequester? = null,
+ contentPadding: PaddingValues = PaddingValues(horizontal = 32.dp),
+) {
+ when (row) {
+ is TvDiscoverRow.FeaturedPodcasts -> item(key = row.id) {
+ TvRow(
+ title = row.title,
+ items = row.podcasts,
+ itemSpacing = 32.dp,
+ contentPadding = contentPadding,
+ key = TvDiscoverPodcast::uuid,
+ focusRequester = focusRequester,
+ modifier = modifier,
+ ) { podcast ->
+ TvFeaturedTile(
+ artworkUrl = podcast.artworkUrl,
+ isSponsored = podcast.isSponsored,
+ title = podcast.title,
+ description = podcast.description,
+ onGoToPodcast = { onOpenPodcast(podcast.uuid) },
+ onPlayLastEpisode = {},
+ )
+ }
+ }
+
+ is TvDiscoverRow.SinglePodcast -> item(key = row.id) {
+ TvRow(
+ title = row.title,
+ items = row.podcasts,
+ itemSpacing = 32.dp,
+ contentPadding = contentPadding,
+ key = TvDiscoverPodcast::uuid,
+ focusRequester = focusRequester,
+ modifier = modifier,
+ ) { podcast ->
+ TvSinglePodcastTile(
+ artworkUrl = podcast.artworkUrl,
+ title = podcast.title,
+ author = podcast.author,
+ description = podcast.description,
+ isSponsored = podcast.isSponsored,
+ onClick = { onOpenPodcast(podcast.uuid) },
+ )
+ }
+ }
+
+ is TvDiscoverRow.Episodes -> item(key = row.id) {
+ TvRow(
+ title = row.title,
+ items = row.episodes,
+ itemSpacing = 32.dp,
+ contentPadding = contentPadding,
+ key = TvDiscoverEpisode::episodeUuid,
+ focusRequester = focusRequester,
+ modifier = modifier,
+ ) { episode ->
+ TvVideoTile(
+ thumbnailUrl = episode.thumbnailUrl,
+ podcastArtworkUrl = episode.podcastArtworkUrl,
+ podcastTitle = episode.podcastTitle,
+ episodeTitle = episode.episodeTitle,
+ onPlayEpisode = { onPlayEpisode(episode) },
+ onGoToPodcast = { onOpenPodcast(episode.podcastUuid) },
+ )
+ }
+ }
+
+ is TvDiscoverRow.Podcasts -> item(key = row.id) {
+ TvRow(
+ title = row.title,
+ items = row.podcasts,
+ contentPadding = contentPadding,
+ key = TvDiscoverPodcast::uuid,
+ focusRequester = focusRequester,
+ modifier = modifier,
+ ) { podcast ->
+ TvPodcastTile(
+ artworkUrl = podcast.artworkUrl,
+ podcastTitle = podcast.title,
+ onClick = { onOpenPodcast(podcast.uuid) },
+ imageModifier = Modifier.width(TvPodcastTileDefaults.RowImageWidth),
+ )
+ }
+ }
+ }
+}
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 aa136dceb59..59a4d1eaa0f 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
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -34,13 +33,12 @@ import androidx.tv.material3.Text
import au.com.shiftyjelly.pocketcasts.component.LocalOpenNowPlaying
import au.com.shiftyjelly.pocketcasts.component.LocalTvToastHostState
import au.com.shiftyjelly.pocketcasts.component.TvDetailOverlay
-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.TvVideoTile
import au.com.shiftyjelly.pocketcasts.component.tvFocusInactiveWhen
import au.com.shiftyjelly.pocketcasts.compose.loading.LoadingView
+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.tvDiscoverRow
import au.com.shiftyjelly.pocketcasts.podcasts.TvPodcastDetailsScreen
import au.com.shiftyjelly.pocketcasts.theme.TvTheme
import au.com.shiftyjelly.pocketcasts.theme.TvTopBarHeight
@@ -101,7 +99,7 @@ private fun TvHomeContent(
uiState: TvHomeUiState,
onRetry: () -> Unit,
onOpenPodcast: (String) -> Unit,
- onPlayEpisode: (TvHomeEpisode) -> Unit,
+ onPlayEpisode: (TvDiscoverEpisode) -> Unit,
modifier: Modifier = Modifier,
restoreFocusTrigger: Int = 0,
) {
@@ -149,9 +147,9 @@ private fun TvHomeError(
@Composable
private fun TvHomeRows(
- rows: List,
+ rows: List,
onOpenPodcast: (String) -> Unit,
- onPlayEpisode: (TvHomeEpisode) -> Unit,
+ onPlayEpisode: (TvDiscoverEpisode) -> Unit,
modifier: Modifier = Modifier,
restoreFocusTrigger: Int = 0,
) {
@@ -181,64 +179,13 @@ private fun TvHomeRows(
}
}
val rowFocusRequester = rowFocusRequesters[rowIndex]
- when (row) {
- is TvHomeRow.FeaturedPodcasts -> item(key = row.id) {
- TvRow(
- title = row.title,
- items = row.podcasts,
- itemSpacing = 32.dp,
- key = TvHomePodcast::uuid,
- focusRequester = rowFocusRequester,
- modifier = rowModifier,
- ) { podcast ->
- TvFeaturedTile(
- artworkUrl = podcast.artworkUrl,
- isSponsored = podcast.isSponsored,
- title = podcast.title,
- description = podcast.description,
- onGoToPodcast = { onOpenPodcast(podcast.uuid) },
- onPlayLastEpisode = {},
- )
- }
- }
-
- is TvHomeRow.Episodes -> item(key = row.id) {
- TvRow(
- title = row.title,
- items = row.episodes,
- itemSpacing = 32.dp,
- key = TvHomeEpisode::episodeUuid,
- focusRequester = rowFocusRequester,
- modifier = rowModifier,
- ) { episode ->
- TvVideoTile(
- thumbnailUrl = episode.thumbnailUrl,
- podcastArtworkUrl = episode.podcastArtworkUrl,
- podcastTitle = episode.podcastTitle,
- episodeTitle = episode.episodeTitle,
- onPlayEpisode = { onPlayEpisode(episode) },
- onGoToPodcast = { onOpenPodcast(episode.podcastUuid) },
- )
- }
- }
-
- is TvHomeRow.Podcasts -> item(key = row.id) {
- TvRow(
- title = row.title,
- items = row.podcasts,
- key = TvHomePodcast::uuid,
- focusRequester = rowFocusRequester,
- modifier = rowModifier,
- ) { podcast ->
- TvPodcastTile(
- artworkUrl = podcast.artworkUrl,
- podcastTitle = podcast.title,
- onClick = { onOpenPodcast(podcast.uuid) },
- imageModifier = Modifier.width(TvPodcastTileDefaults.RowImageWidth),
- )
- }
- }
- }
+ tvDiscoverRow(
+ row = row,
+ onOpenPodcast = onOpenPodcast,
+ onPlayEpisode = onPlayEpisode,
+ modifier = rowModifier,
+ focusRequester = rowFocusRequester,
+ )
}
item { Spacer(modifier = Modifier.height(8.dp)) }
@@ -253,16 +200,16 @@ private fun TvHomeContentPreview() {
TvHomeContent(
uiState = TvHomeUiState.Ready(
rows = listOf(
- TvHomeRow.FeaturedPodcasts(
+ TvDiscoverRow.FeaturedPodcasts(
id = "featured",
title = "Featured",
podcasts = (1..3).map { previewPodcast(it) },
),
- TvHomeRow.Episodes(
+ TvDiscoverRow.Episodes(
id = "tv_featured_videos",
title = "Made for TV",
episodes = (1..6).map {
- TvHomeEpisode(
+ TvDiscoverEpisode(
episodeUuid = "episode-$it",
episodeTitle = "Episode $it",
podcastUuid = "podcast-$it",
@@ -270,7 +217,7 @@ private fun TvHomeContentPreview() {
)
},
),
- TvHomeRow.Podcasts(
+ TvDiscoverRow.Podcasts(
id = "trending",
title = "Trending",
podcasts = (1..8).map { previewPodcast(it) },
@@ -300,8 +247,9 @@ private fun TvHomeErrorPreview() {
}
}
-private fun previewPodcast(index: Int) = TvHomePodcast(
+private fun previewPodcast(index: Int) = TvDiscoverPodcast(
uuid = "podcast-$index",
title = "Podcast $index",
+ author = "Author $index",
description = "Description of podcast $index",
)
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 ca84d7e573f..5da7a487f52 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
@@ -4,32 +4,25 @@ import android.content.Context
import androidx.lifecycle.ViewModel
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.TvDiscoverRow
import au.com.shiftyjelly.pocketcasts.models.db.dao.PodcastDao
import au.com.shiftyjelly.pocketcasts.models.db.dao.UpNextDao
import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode
import au.com.shiftyjelly.pocketcasts.models.type.SmartRules
-import au.com.shiftyjelly.pocketcasts.preferences.Settings
-import au.com.shiftyjelly.pocketcasts.repositories.images.PodcastImage
-import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository
import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager
import au.com.shiftyjelly.pocketcasts.repositories.playlist.PlaylistManager
import au.com.shiftyjelly.pocketcasts.repositories.playlist.SmartPlaylistDraft
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.DiscoverEpisode
-import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverPodcast
-import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverRow
-import au.com.shiftyjelly.pocketcasts.servers.model.DisplayStyle
-import au.com.shiftyjelly.pocketcasts.servers.model.ListType
-import au.com.shiftyjelly.pocketcasts.servers.model.transformWithRegion
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -47,11 +40,10 @@ import au.com.shiftyjelly.pocketcasts.localization.R as LR
@HiltViewModel
class TvHomeViewModel @Inject constructor(
- private val listRepository: ListRepository,
+ private val discoverFeedLoader: TvDiscoverFeedLoader,
private val playlistManager: PlaylistManager,
private val podcastDao: PodcastDao,
private val upNextDao: UpNextDao,
- private val settings: Settings,
private val syncManager: SyncManager,
private val episodeManager: EpisodeManager,
private val podcastManager: PodcastManager,
@@ -93,12 +85,12 @@ class TvHomeViewModel @Inject constructor(
}
}
- private suspend fun loadRows(): List = coroutineScope {
+ private suspend fun loadRows(): List = coroutineScope {
val isLoggedIn = syncManager.isLoggedIn()
val localRowsDeferred = async { loadLocalRows(isLoggedIn) }
val discoverRowsDeferred = async {
try {
- Result.success(loadDiscoverRows(isLoggedIn))
+ Result.success(discoverFeedLoader.load(isLoggedIn))
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
@@ -112,29 +104,10 @@ class TvHomeViewModel @Inject constructor(
Timber.e(exception, "Failed to load TV discover rows")
emptyList()
}
- (localRows + discoverRows).distinctBy(TvHomeRow::id)
+ (localRows + discoverRows).distinctBy(TvDiscoverRow::id)
}
- private suspend fun loadDiscoverRows(isLoggedIn: Boolean): List = coroutineScope {
- val discover = listRepository.getDiscoverFeed()
- 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,
- )
-
- 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) } }
- .awaitAll()
- .filterNotNull()
- }
-
- private suspend fun loadLocalRows(isLoggedIn: Boolean): List = coroutineScope {
+ private suspend fun loadLocalRows(isLoggedIn: Boolean): List = coroutineScope {
val upNextEpisodesDeferred = async {
upNextDao.getUpNextBaseEpisodes(limit = UP_NEXT_LIMIT + 1).filterIsInstance()
}
@@ -155,10 +128,10 @@ class TvHomeViewModel @Inject constructor(
buildList {
upNextEpisodes.firstOrNull()?.let { current ->
add(
- TvHomeRow.Episodes(
+ TvDiscoverRow.Episodes(
id = KEEP_LISTENING_ROW_ID,
title = context.getString(LR.string.tv_home_keep_listening),
- episodes = listOf(current.toTvHomeEpisode(podcastTitles)),
+ episodes = listOf(current.toTvDiscoverEpisode(podcastTitles)),
),
)
}
@@ -166,19 +139,19 @@ class TvHomeViewModel @Inject constructor(
val queue = upNextEpisodes.drop(1)
if (queue.isNotEmpty()) {
add(
- TvHomeRow.Episodes(
+ TvDiscoverRow.Episodes(
id = UP_NEXT_ROW_ID,
title = context.getString(LR.string.up_next),
- episodes = queue.map { it.toTvHomeEpisode(podcastTitles) },
+ episodes = queue.map { it.toTvDiscoverEpisode(podcastTitles) },
),
)
}
if (newReleases.isNotEmpty()) {
add(
- TvHomeRow.Episodes(
+ TvDiscoverRow.Episodes(
id = NEW_RELEASES_ROW_ID,
title = context.getString(LR.string.filters_title_new_releases),
- episodes = newReleases.map { it.toTvHomeEpisode(podcastTitles) },
+ episodes = newReleases.map { it.toTvDiscoverEpisode(podcastTitles) },
),
)
}
@@ -186,7 +159,7 @@ class TvHomeViewModel @Inject constructor(
}
}
- fun playEpisode(episode: TvHomeEpisode) {
+ fun playEpisode(episode: TvDiscoverEpisode) {
viewModelScope.launch {
try {
val found = episodeManager.findByUuid(episode.episodeUuid)
@@ -216,63 +189,13 @@ class TvHomeViewModel @Inject constructor(
return podcastDao.findAllIn(uuids).associate { it.uuid to it.title }
}
- private fun PodcastEpisode.toTvHomeEpisode(podcastTitles: Map) = TvHomeEpisode(
+ private fun PodcastEpisode.toTvDiscoverEpisode(podcastTitles: Map) = TvDiscoverEpisode(
episodeUuid = uuid,
episodeTitle = title,
podcastUuid = podcastUuid,
podcastTitle = podcastTitles[podcastUuid].orEmpty(),
)
- private suspend fun loadRow(row: DiscoverRow): TvHomeRow? {
- return when (row.type) {
- is ListType.PodcastList -> loadPodcastsRow(row)
- is ListType.EpisodeList -> loadEpisodesRow(row)
- is ListType.Categories, is ListType.Unknown -> null
- }
- }
-
- private suspend fun loadPodcastsRow(row: DiscoverRow): TvHomeRow? {
- val feed = listRepository.getListFeed(row.source, row.authenticated) ?: return null
- val podcasts = feed.podcasts.orEmpty()
- .distinctBy(DiscoverPodcast::uuid)
- .map { it.toTvHomePodcast(isSponsored = row.sponsored) }
- if (podcasts.isEmpty()) return null
- val title = feed.title?.takeIf { it.isNotBlank() } ?: row.title
- return when (row.displayStyle) {
- is DisplayStyle.Carousel, is DisplayStyle.SinglePodcast -> {
- TvHomeRow.FeaturedPodcasts(id = row.rowId(), title = title, podcasts = podcasts)
- }
-
- else -> TvHomeRow.Podcasts(id = row.rowId(), title = title, podcasts = podcasts)
- }
- }
-
- private suspend fun loadEpisodesRow(row: DiscoverRow): TvHomeRow? {
- val feed = listRepository.getListFeed(row.source, row.authenticated) ?: return null
- val episodes = feed.episodes.orEmpty()
- .distinctBy(DiscoverEpisode::uuid)
- .map { episode ->
- TvHomeEpisode(
- episodeUuid = episode.uuid,
- episodeTitle = episode.title.orEmpty(),
- podcastUuid = episode.podcast_uuid,
- podcastTitle = episode.podcast_title.orEmpty(),
- )
- }
- if (episodes.isEmpty()) return null
- val title = feed.title?.takeIf { it.isNotBlank() } ?: row.title
- return TvHomeRow.Episodes(id = row.rowId(), title = title, episodes = episodes)
- }
-
- private fun DiscoverRow.rowId() = listUuid ?: id ?: title
-
- private fun DiscoverPodcast.toTvHomePodcast(isSponsored: Boolean) = TvHomePodcast(
- uuid = uuid,
- title = title.orEmpty(),
- description = description.orEmpty(),
- isSponsored = isSponsored,
- )
-
companion object {
const val KEEP_LISTENING_ROW_ID = "keep_listening"
const val UP_NEXT_ROW_ID = "up_next"
@@ -290,47 +213,5 @@ class TvHomeViewModel @Inject constructor(
sealed interface TvHomeUiState {
data object Loading : TvHomeUiState
data object Error : TvHomeUiState
- data class Ready(val rows: List) : TvHomeUiState
-}
-
-sealed interface TvHomeRow {
- val id: String
- val title: String
-
- data class FeaturedPodcasts(
- override val id: String,
- override val title: String,
- val podcasts: List,
- ) : TvHomeRow
-
- data class Podcasts(
- override val id: String,
- override val title: String,
- val podcasts: List,
- ) : TvHomeRow
-
- data class Episodes(
- override val id: String,
- override val title: String,
- val episodes: List,
- ) : TvHomeRow
-}
-
-data class TvHomePodcast(
- val uuid: String,
- val title: String,
- val description: String,
- val isSponsored: Boolean = false,
-) {
- val artworkUrl: String = PodcastImage.getMediumArtworkUrl(uuid)
-}
-
-data class TvHomeEpisode(
- val episodeUuid: String,
- val episodeTitle: String,
- val podcastUuid: String,
- val podcastTitle: String,
-) {
- val thumbnailUrl: String = PodcastImage.getArtworkUrl(size = 960, uuid = podcastUuid, isWearOS = false)
- val podcastArtworkUrl: String = PodcastImage.getArtworkUrl(size = 200, uuid = podcastUuid, isWearOS = false)
+ data class Ready(val rows: List) : TvHomeUiState
}
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 9dca3732b59..8d564e1776c 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
@@ -32,6 +32,7 @@ import au.com.shiftyjelly.pocketcasts.component.TvTopBarVisibility
import au.com.shiftyjelly.pocketcasts.nowplaying.TvNowPlayingScreen
import au.com.shiftyjelly.pocketcasts.playlists.TvPlaylistsScreen
import au.com.shiftyjelly.pocketcasts.podcasts.TvYourPodcastsScreen
+import au.com.shiftyjelly.pocketcasts.search.TvSearchScreen
import au.com.shiftyjelly.pocketcasts.theme.TvScreenBackgroundBrush
import au.com.shiftyjelly.pocketcasts.theme.TvTheme
import au.com.shiftyjelly.pocketcasts.theme.TvTopBarHeight
@@ -105,8 +106,8 @@ fun TvScaffold(
onConsumeOpenRequest = { isNowPlayingOpenRequested = false },
)
- else -> Box(modifier = belowTopBar) {
- TvTabPlaceholder(tab = tab)
+ is TvTab.Search -> Box(modifier = belowTopBar) {
+ TvSearchScreen()
}
}
}
diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt
new file mode 100644
index 00000000000..2db2dfd9ea2
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchField.kt
@@ -0,0 +1,101 @@
+package au.com.shiftyjelly.pocketcasts.search
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Icon
+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 au.com.shiftyjelly.pocketcasts.images.R as IR
+import au.com.shiftyjelly.pocketcasts.localization.R as LR
+
+@Composable
+internal fun TvSearchField(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ autoFocus: Boolean = false,
+) {
+ val focusRequester = remember { FocusRequester() }
+ val keyboardController = LocalSoftwareKeyboardController.current
+ if (autoFocus) {
+ LaunchedEffect(Unit) {
+ runCatching { focusRequester.requestFocus() }
+ keyboardController?.show()
+ }
+ }
+
+ BasicTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ singleLine = true,
+ textStyle = MaterialTheme.tvTypography.title2.copy(color = MaterialTheme.tvColors.textPrimary),
+ cursorBrush = SolidColor(MaterialTheme.tvColors.textPrimary),
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrectEnabled = false,
+ imeAction = ImeAction.Search,
+ ),
+ modifier = modifier.focusRequester(focusRequester),
+ decorationBox = { innerTextField ->
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ painter = painterResource(IR.drawable.ic_search),
+ contentDescription = null,
+ tint = MaterialTheme.tvColors.textSecondary,
+ modifier = Modifier.size(40.dp),
+ )
+ Spacer(modifier = Modifier.width(20.dp))
+ Box(contentAlignment = Alignment.CenterStart) {
+ if (query.isEmpty()) {
+ Text(
+ text = stringResource(LR.string.search),
+ style = MaterialTheme.tvTypography.title2,
+ color = MaterialTheme.tvColors.textSecondary,
+ )
+ }
+ innerTextField()
+ }
+ }
+ },
+ )
+}
+
+@Preview(device = Devices.TV_1080p)
+@Composable
+private fun TvSearchFieldPreview() {
+ TvTheme {
+ Box(
+ modifier = Modifier
+ .background(MaterialTheme.tvColors.backgroundSunken)
+ .padding(48.dp),
+ ) {
+ TvSearchField(query = "huberman", onQueryChange = {})
+ }
+ }
+}
diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt
new file mode 100644
index 00000000000..2af7b698656
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboard.kt
@@ -0,0 +1,341 @@
+package au.com.shiftyjelly.pocketcasts.search
+
+import android.view.KeyCharacterMap
+import android.view.KeyEvent.KEYCODE_DEL
+import android.view.KeyEvent.KEYCODE_DPAD_CENTER
+import android.view.KeyEvent.KEYCODE_DPAD_LEFT
+import android.view.KeyEvent.KEYCODE_DPAD_RIGHT
+import android.view.KeyEvent.KEYCODE_ENTER
+import android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
+import android.view.KeyEvent.KEYCODE_SEARCH
+import android.view.KeyEvent.KEYCODE_SPACE
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.nativeKeyCode
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.text.style.TextAlign
+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.LayoutDirection
+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 au.com.shiftyjelly.pocketcasts.localization.R as LR
+
+internal sealed interface TvSearchKey {
+ data class Character(val char: Char) : TvSearchKey
+ data object Space : TvSearchKey
+ data object Delete : TvSearchKey
+ data object TogglePage : TvSearchKey
+}
+
+private enum class TvSearchKeyboardPage { Letters, Symbols }
+
+private val LettersKeys: List = buildList {
+ add(TvSearchKey.TogglePage)
+ add(TvSearchKey.Space)
+ ('a'..'z').forEach { add(TvSearchKey.Character(it)) }
+ add(TvSearchKey.Delete)
+}
+
+private val SymbolsKeys: List = buildList {
+ add(TvSearchKey.TogglePage)
+ add(TvSearchKey.Space)
+ ('0'..'9').forEach { add(TvSearchKey.Character(it)) }
+ ".,'-_&@".forEach { add(TvSearchKey.Character(it)) }
+ add(TvSearchKey.Delete)
+}
+
+private val InitialSelectedIndex = LettersKeys.indexOfFirst { it is TvSearchKey.Character }
+
+@Stable
+internal class TvSearchKeyboardState {
+ private var page by mutableStateOf(TvSearchKeyboardPage.Letters)
+
+ var selectedIndex by mutableIntStateOf(InitialSelectedIndex)
+ private set
+
+ var isFocused by mutableStateOf(false)
+ private set
+
+ private var consumedLastLeftRight = false
+
+ val keys: List get() = if (page == TvSearchKeyboardPage.Letters) LettersKeys else SymbolsKeys
+ val isSymbolsPage: Boolean get() = page == TvSearchKeyboardPage.Symbols
+ val selectedKey: TvSearchKey get() = keys[selectedIndex]
+
+ fun onFocusChanged(focused: Boolean) {
+ isFocused = focused
+ }
+
+ fun togglePage() {
+ page = if (page == TvSearchKeyboardPage.Letters) TvSearchKeyboardPage.Symbols else TvSearchKeyboardPage.Letters
+ if (selectedIndex > keys.lastIndex) {
+ selectedIndex = keys.lastIndex
+ }
+ }
+
+ fun isSelected(index: Int): Boolean = isFocused && selectedIndex == index
+
+ fun handleDpadDirection(keyCode: Int, isKeyDown: Boolean): Boolean {
+ if (keyCode != KEYCODE_DPAD_LEFT && keyCode != KEYCODE_DPAD_RIGHT) return false
+ return if (isKeyDown) {
+ consumedLastLeftRight = when (keyCode) {
+ KEYCODE_DPAD_RIGHT -> (selectedIndex < keys.lastIndex).also { if (it) selectedIndex++ }
+ KEYCODE_DPAD_LEFT -> (selectedIndex > 0).also { if (it) selectedIndex-- }
+ else -> false
+ }
+ consumedLastLeftRight
+ } else {
+ consumedLastLeftRight.also { consumedLastLeftRight = false }
+ }
+ }
+}
+
+@Composable
+internal fun rememberTvSearchKeyboardState(): TvSearchKeyboardState = remember { TvSearchKeyboardState() }
+
+@Composable
+internal fun TvSearchKeyboard(
+ onCharacter: (Char) -> Unit,
+ onSpace: () -> Unit,
+ onDelete: () -> Unit,
+ onSubmit: () -> Unit,
+ modifier: Modifier = Modifier,
+ autoFocus: Boolean = false,
+ state: TvSearchKeyboardState = rememberTvSearchKeyboardState(),
+) {
+ val focusRequester = remember { FocusRequester() }
+ if (autoFocus) {
+ LaunchedEffect(Unit) {
+ withFrameNanos {}
+ runCatching { focusRequester.requestFocus() }
+ }
+ }
+
+ fun activate(key: TvSearchKey) {
+ when (key) {
+ is TvSearchKey.Character -> onCharacter(key.char)
+ TvSearchKey.Space -> onSpace()
+ TvSearchKey.Delete -> onDelete()
+ TvSearchKey.TogglePage -> state.togglePage()
+ }
+ }
+
+ val keyboardDescription = stringResource(LR.string.tv_search_keyboard)
+ val selectedKeyLabel = when (val key = state.selectedKey) {
+ is TvSearchKey.Character -> key.char.toString()
+
+ TvSearchKey.Space -> stringResource(LR.string.tv_search_key_space)
+
+ TvSearchKey.Delete -> stringResource(LR.string.delete)
+
+ TvSearchKey.TogglePage -> stringResource(
+ if (state.isSymbolsPage) LR.string.tv_search_key_letters else LR.string.tv_search_key_symbols,
+ )
+ }
+
+ Box(modifier = modifier, propagateMinConstraints = true) {
+ CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .onFocusChanged { state.onFocusChanged(it.isFocused) }
+ .semantics(mergeDescendants = true) {
+ contentDescription = keyboardDescription
+ stateDescription = selectedKeyLabel
+ liveRegion = LiveRegionMode.Polite
+ }
+ .onPreviewKeyEvent { event ->
+ val keyCode = event.key.nativeKeyCode
+ val isKeyDown = event.type == KeyEventType.KeyDown
+ when (keyCode) {
+ KEYCODE_DPAD_LEFT, KEYCODE_DPAD_RIGHT -> state.handleDpadDirection(keyCode, isKeyDown)
+
+ KEYCODE_DPAD_CENTER, KEYCODE_ENTER, KEYCODE_NUMPAD_ENTER -> {
+ if (isKeyDown) activate(state.selectedKey)
+ true
+ }
+
+ KEYCODE_SEARCH -> {
+ if (isKeyDown) onSubmit()
+ true
+ }
+
+ KEYCODE_DEL -> {
+ if (isKeyDown) onDelete()
+ true
+ }
+
+ KEYCODE_SPACE -> {
+ if (isKeyDown) onSpace()
+ true
+ }
+
+ else -> {
+ val nativeEvent = event.nativeKeyEvent
+ if (nativeEvent.isCtrlPressed || nativeEvent.isMetaPressed) {
+ return@onPreviewKeyEvent false
+ }
+ val unicodeChar = nativeEvent.getUnicodeChar(nativeEvent.metaState)
+ val isPrintable = unicodeChar != 0 &&
+ (unicodeChar and KeyCharacterMap.COMBINING_ACCENT) == 0 &&
+ !Character.isISOControl(unicodeChar)
+ if (isPrintable) {
+ if (isKeyDown) onCharacter(unicodeChar.toChar())
+ true
+ } else {
+ false
+ }
+ }
+ }
+ }
+ .focusable(),
+ ) {
+ state.keys.forEachIndexed { index, key ->
+ TvSearchKeyCap(
+ key = key,
+ selected = state.isSelected(index),
+ isSymbolsPage = state.isSymbolsPage,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TvSearchKeyCap(
+ key: TvSearchKey,
+ selected: Boolean,
+ isSymbolsPage: Boolean,
+) {
+ val hasPersistentBackground = key is TvSearchKey.Space || key is TvSearchKey.TogglePage
+ val background = when {
+ selected -> MaterialTheme.tvColors.backgroundActive
+ hasPersistentBackground -> MaterialTheme.tvColors.backgroundActive20
+ else -> Color.Transparent
+ }
+ val contentColor = if (selected) MaterialTheme.tvColors.textPrimaryActive else MaterialTheme.tvColors.textSecondary
+ val shape = RoundedCornerShape(8.dp)
+ val sizeModifier = when (key) {
+ is TvSearchKey.Character -> Modifier.width(24.dp).height(48.dp).clip(shape).background(background)
+
+ TvSearchKey.Delete -> Modifier.width(42.dp).height(48.dp).clip(shape).background(background)
+
+ TvSearchKey.Space -> Modifier.width(60.dp).clip(shape).background(background).padding(vertical = 6.dp)
+
+ TvSearchKey.TogglePage ->
+ Modifier.clip(shape).background(background).padding(horizontal = 12.dp, vertical = 6.dp)
+ }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .scale(if (selected) 1.25f else 1f)
+ .then(sizeModifier),
+ ) {
+ when (key) {
+ is TvSearchKey.Character -> Text(
+ text = key.char.toString(),
+ style = MaterialTheme.tvTypography.subtitle1,
+ color = contentColor,
+ )
+
+ TvSearchKey.Space -> Text(
+ text = stringResource(LR.string.tv_search_key_space),
+ style = MaterialTheme.tvTypography.caption1,
+ color = contentColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = TextAlign.Center,
+ )
+
+ TvSearchKey.Delete -> Text(
+ text = "⌫",
+ style = MaterialTheme.tvTypography.title3,
+ color = contentColor,
+ )
+
+ TvSearchKey.TogglePage -> Text(
+ text = stringResource(
+ if (isSymbolsPage) LR.string.tv_search_key_letters else LR.string.tv_search_key_symbols,
+ ),
+ style = MaterialTheme.tvTypography.caption1,
+ color = contentColor,
+ )
+ }
+ }
+}
+
+@Preview(device = Devices.TV_1080p)
+@Composable
+private fun TvSearchKeyboardPreview() {
+ TvTheme {
+ var query by remember { mutableStateOf("") }
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.tvColors.backgroundSunken)
+ .padding(48.dp),
+ ) {
+ Text(
+ text = query.ifEmpty { "Type with the remote or a keyboard…" },
+ style = MaterialTheme.tvTypography.title2,
+ color = MaterialTheme.tvColors.textPrimary,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ TvSearchKeyboard(
+ onCharacter = { query += it },
+ onSpace = { query += ' ' },
+ onDelete = { query = query.dropLast(1) },
+ onSubmit = {},
+ autoFocus = true,
+ )
+ }
+ }
+}
diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt
new file mode 100644
index 00000000000..c17c660e44f
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt
@@ -0,0 +1,132 @@
+package au.com.shiftyjelly.pocketcasts.search
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+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.lazy.LazyColumn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+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.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.tv.material3.MaterialTheme
+import au.com.shiftyjelly.pocketcasts.component.TvCategoryTile
+import au.com.shiftyjelly.pocketcasts.component.TvRow
+import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow
+import au.com.shiftyjelly.pocketcasts.discover.tvDiscoverRow
+import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory
+import au.com.shiftyjelly.pocketcasts.theme.TvTheme
+import au.com.shiftyjelly.pocketcasts.theme.tvColors
+import au.com.shiftyjelly.pocketcasts.localization.R as LR
+
+private val ContentPadding = PaddingValues(horizontal = 48.dp)
+
+@Composable
+fun TvSearchScreen(
+ modifier: Modifier = Modifier,
+ viewModel: TvSearchViewModel = hiltViewModel(),
+) {
+ var query by rememberSaveable { mutableStateOf("") }
+ val categories by viewModel.categories.collectAsStateWithLifecycle()
+ val discoverRows by viewModel.discoverRows.collectAsStateWithLifecycle()
+
+ TvSearchContent(
+ query = query,
+ categories = categories,
+ discoverRows = discoverRows,
+ onQueryChange = { query = it },
+ modifier = modifier,
+ )
+}
+
+@Composable
+private fun TvSearchContent(
+ query: String,
+ categories: List,
+ discoverRows: List,
+ onQueryChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyColumn(modifier = modifier.fillMaxSize()) {
+ item {
+ Column(modifier = Modifier.padding(ContentPadding)) {
+ Spacer(modifier = Modifier.height(40.dp))
+ TvSearchField(
+ query = query,
+ onQueryChange = onQueryChange,
+ autoFocus = true,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+
+ if (categories.isNotEmpty()) {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(ContentPadding)
+ .height(1.dp)
+ .background(MaterialTheme.tvColors.overlayBorder),
+ )
+ }
+ item {
+ Spacer(modifier = Modifier.height(24.dp))
+ TvRow(
+ title = stringResource(LR.string.tv_search_browse_categories),
+ items = categories,
+ contentPadding = ContentPadding,
+ key = { it.id },
+ ) { category ->
+ TvCategoryTile(category = category, onClick = {})
+ }
+ }
+ }
+
+ if (query.isBlank()) {
+ discoverRows.forEach { row ->
+ item { Spacer(modifier = Modifier.height(24.dp)) }
+ tvDiscoverRow(
+ row = row,
+ onOpenPodcast = {},
+ onPlayEpisode = {},
+ contentPadding = ContentPadding,
+ )
+ }
+ }
+
+ item { Spacer(modifier = Modifier.height(40.dp)) }
+ }
+}
+
+@Preview(device = Devices.TV_1080p)
+@Composable
+private fun TvSearchScreenPreview() {
+ TvTheme {
+ Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.tvColors.backgroundSunken)) {
+ TvSearchContent(
+ query = "",
+ categories = listOf(
+ DiscoverCategory(id = 1, name = "Comedy", icon = "", source = ""),
+ DiscoverCategory(id = 2, name = "True Crime", icon = "", source = ""),
+ DiscoverCategory(id = 3, name = "Fiction", icon = "", source = ""),
+ ),
+ discoverRows = emptyList(),
+ onQueryChange = {},
+ )
+ }
+ }
+}
diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt
new file mode 100644
index 00000000000..bf3de40f98d
--- /dev/null
+++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModel.kt
@@ -0,0 +1,55 @@
+package au.com.shiftyjelly.pocketcasts.search
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader
+import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow
+import au.com.shiftyjelly.pocketcasts.repositories.sync.SyncManager
+import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory
+import dagger.hilt.android.lifecycle.HiltViewModel
+import javax.inject.Inject
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import timber.log.Timber
+
+@HiltViewModel
+class TvSearchViewModel @Inject constructor(
+ private val discoverFeedLoader: TvDiscoverFeedLoader,
+ private val syncManager: SyncManager,
+) : ViewModel() {
+
+ private val _categories = MutableStateFlow>(emptyList())
+ val categories: StateFlow> = _categories.asStateFlow()
+
+ private val _discoverRows = MutableStateFlow>(emptyList())
+ val discoverRows: StateFlow> = _discoverRows.asStateFlow()
+
+ init {
+ viewModelScope.launch {
+ val discover = try {
+ discoverFeedLoader.searchDiscoverFeed()
+ } catch (exception: CancellationException) {
+ throw exception
+ } catch (exception: Exception) {
+ Timber.e(exception, "Failed to load TV search discover feed")
+ return@launch
+ }
+ // Publish categories and rows independently so the categories row (2 requests) does not
+ // wait for the whole row fan-out (~10-20 requests) to resolve.
+ launch { _categories.value = discoverFeedLoader.loadCategories(discover) }
+ launch {
+ _discoverRows.value = try {
+ discoverFeedLoader.buildRows(discover, syncManager.isLoggedIn())
+ } catch (exception: CancellationException) {
+ throw exception
+ } catch (exception: Exception) {
+ Timber.e(exception, "Failed to load TV search discover rows")
+ emptyList()
+ }
+ }
+ }
+ }
+}
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 c8316124135..df22cf8ccb8 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,9 @@ 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.TvDiscoverEpisode
+import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader
+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
import au.com.shiftyjelly.pocketcasts.models.entity.Podcast
@@ -89,7 +92,7 @@ class TvHomeViewModelTest {
@Test
fun `all rows load in feed order`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenReturn(
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(
discover(
row(
id = "featured",
@@ -142,18 +145,18 @@ class TvHomeViewModelTest {
listOf("featured", "sponsored-id", "videos-id", "trending", "curated-id"),
state.rows.map { it.id },
)
- assertTrue(state.rows[0] is TvHomeRow.FeaturedPodcasts)
- assertTrue(state.rows[1] is TvHomeRow.FeaturedPodcasts)
- assertTrue(state.rows[2] is TvHomeRow.Episodes)
- assertTrue(state.rows[3] is TvHomeRow.Podcasts)
- assertTrue(state.rows[4] is TvHomeRow.Podcasts)
+ assertTrue(state.rows[0] is TvDiscoverRow.FeaturedPodcasts)
+ assertTrue(state.rows[1] is TvDiscoverRow.SinglePodcast)
+ assertTrue(state.rows[2] is TvDiscoverRow.Episodes)
+ assertTrue(state.rows[3] is TvDiscoverRow.Podcasts)
+ assertTrue(state.rows[4] is TvDiscoverRow.Podcasts)
}
}
@Test
fun `authenticated rows are excluded when signed out`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenReturn(
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(
discover(
row(
id = "recommendations_user",
@@ -176,10 +179,31 @@ class TvHomeViewModelTest {
verify(listRepository, never()).getListFeed(eq("https://lists/user.json"), any())
}
+ @Test
+ fun `rows without a source are dropped`() = runTest {
+ whenever(syncManager.isLoggedIn()).thenReturn(false)
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(
+ discover(
+ row(id = "up-next-placeholder", title = "Up Next", source = ""),
+ 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 })
+ }
+ verify(listRepository, never()).getListFeed(eq(""), any())
+ }
+
@Test
fun `authenticated rows load when signed in`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(true)
- whenever(listRepository.getDiscoverFeed()).thenReturn(
+ whenever(listRepository.getLoggedInDiscoverFeed()).thenReturn(
discover(
row(
id = "recommendations_user",
@@ -206,7 +230,7 @@ class TvHomeViewModelTest {
@Test
fun `sponsored row marks its podcasts as sponsored`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenReturn(
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(
discover(
row(
id = "sponsored-id",
@@ -224,7 +248,7 @@ class TvHomeViewModelTest {
viewModel.uiState.test {
val state = awaitItem() as TvHomeUiState.Ready
- val row = state.rows.single() as TvHomeRow.FeaturedPodcasts
+ val row = state.rows.single() as TvDiscoverRow.SinglePodcast
assertTrue(row.podcasts.single().isSponsored)
}
}
@@ -232,7 +256,7 @@ class TvHomeViewModelTest {
@Test
fun `rows that fail to load are dropped`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenReturn(
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(
discover(
row(id = "featured", title = "Row Featured", source = "https://lists/featured.json"),
row(id = "empty-id", title = "Row Empty", source = "https://lists/empty.json"),
@@ -255,7 +279,7 @@ class TvHomeViewModelTest {
@Test
fun `category sponsor rows are excluded`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenReturn(
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(
discover(
row(
id = "category-ad",
@@ -282,7 +306,7 @@ class TvHomeViewModelTest {
@Test
fun `rows with duplicate ids are deduplicated`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenReturn(
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(
discover(
row(id = "trending", title = "Row Trending", source = "https://lists/trending.json"),
row(id = "trending", title = "Row Trending Again", source = "https://lists/trending-2.json"),
@@ -304,7 +328,7 @@ class TvHomeViewModelTest {
@Test
fun `rows not available in the current region are excluded`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenReturn(
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(
discover(
row(id = "featured", title = "Row Featured", source = "https://lists/featured.json"),
row(
@@ -330,7 +354,7 @@ class TvHomeViewModelTest {
@Test
fun `list feed title is preferred over row title`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenReturn(
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(
discover(
row(id = "featured", title = "Row Featured", source = "https://lists/featured.json"),
row(id = "trending", title = "Row Trending", source = "https://lists/trending.json"),
@@ -352,7 +376,7 @@ class TvHomeViewModelTest {
@Test
fun `feed failure shows error state and retry reloads`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed())
+ whenever(listRepository.getLoggedOutDiscoverFeed())
.thenThrow(RuntimeException("Network error"))
.thenReturn(discover(row(id = "trending", title = "Row Trending", source = "https://lists/trending.json")))
whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any()))
@@ -374,7 +398,7 @@ class TvHomeViewModelTest {
@Test
fun `feed failure keeps local rows on screen`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenThrow(RuntimeException("Network error"))
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenThrow(RuntimeException("Network error"))
whenever(upNextDao.getUpNextBaseEpisodes(any())).thenReturn(
listOf(episode(uuid = "episode-1", podcastUuid = "podcast-1")),
)
@@ -392,7 +416,7 @@ class TvHomeViewModelTest {
@Test
fun `keep listening row shows first up next episode even when signed out`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenReturn(discover())
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(discover())
whenever(upNextDao.getUpNextBaseEpisodes(any())).thenReturn(
listOf(
episode(uuid = "episode-1", podcastUuid = "podcast-1"),
@@ -407,7 +431,7 @@ class TvHomeViewModelTest {
viewModel.uiState.test {
val state = awaitItem() as TvHomeUiState.Ready
assertEquals(listOf(TvHomeViewModel.KEEP_LISTENING_ROW_ID), state.rows.map { it.id })
- val row = state.rows.single() as TvHomeRow.Episodes
+ val row = state.rows.single() as TvDiscoverRow.Episodes
assertEquals("Keep Listening", row.title)
val rowEpisode = row.episodes.single()
assertEquals("episode-1", rowEpisode.episodeUuid)
@@ -418,7 +442,7 @@ class TvHomeViewModelTest {
@Test
fun `user episodes in up next are skipped`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenReturn(discover())
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(discover())
whenever(upNextDao.getUpNextBaseEpisodes(any())).thenReturn(
listOf(
UserEpisode(uuid = "user-file", publishedDate = Date()),
@@ -432,7 +456,7 @@ class TvHomeViewModelTest {
viewModel.uiState.test {
val state = awaitItem() as TvHomeUiState.Ready
- val row = state.rows.single() as TvHomeRow.Episodes
+ val row = state.rows.single() as TvDiscoverRow.Episodes
assertEquals(listOf("episode-1"), row.episodes.map { it.episodeUuid })
}
}
@@ -440,7 +464,7 @@ class TvHomeViewModelTest {
@Test
fun `signed in user sees keep listening, up next and new releases rows before discover rows`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(true)
- whenever(listRepository.getDiscoverFeed()).thenReturn(
+ whenever(listRepository.getLoggedInDiscoverFeed()).thenReturn(
discover(row(id = "trending", title = "Row Trending", source = "https://lists/trending.json")),
)
whenever(listRepository.getListFeed(eq("https://lists/trending.json"), any()))
@@ -475,9 +499,9 @@ class TvHomeViewModelTest {
),
state.rows.map { it.id },
)
- val upNextRow = state.rows[1] as TvHomeRow.Episodes
+ val upNextRow = state.rows[1] as TvDiscoverRow.Episodes
assertEquals(listOf("episode-2", "episode-3"), upNextRow.episodes.map { it.episodeUuid })
- val newReleasesRow = state.rows[2] as TvHomeRow.Episodes
+ val newReleasesRow = state.rows[2] as TvDiscoverRow.Episodes
assertEquals(listOf("episode-new"), newReleasesRow.episodes.map { it.episodeUuid })
assertEquals("Podcast Two", newReleasesRow.episodes.single().podcastTitle)
}
@@ -486,7 +510,7 @@ class TvHomeViewModelTest {
@Test
fun `up next and new releases rows are hidden when signed out`() = runTest {
whenever(syncManager.isLoggedIn()).thenReturn(false)
- whenever(listRepository.getDiscoverFeed()).thenReturn(discover())
+ whenever(listRepository.getLoggedOutDiscoverFeed()).thenReturn(discover())
whenever(upNextDao.getUpNextBaseEpisodes(any())).thenReturn(
listOf(
episode(uuid = "episode-1", podcastUuid = "podcast-1"),
@@ -551,7 +575,7 @@ class TvHomeViewModelTest {
}
}
- private fun homeEpisode() = TvHomeEpisode(
+ private fun homeEpisode() = TvDiscoverEpisode(
episodeUuid = "episode-1",
episodeTitle = "Episode",
podcastUuid = "podcast-1",
@@ -559,11 +583,14 @@ class TvHomeViewModelTest {
)
private fun createViewModel() = TvHomeViewModel(
- listRepository = listRepository,
+ discoverFeedLoader = TvDiscoverFeedLoader(
+ listRepository = listRepository,
+ settings = settings,
+ context = context,
+ ),
playlistManager = playlistManager,
podcastDao = podcastDao,
upNextDao = upNextDao,
- settings = settings,
syncManager = syncManager,
episodeManager = episodeManager,
podcastManager = podcastManager,
diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboardStateTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboardStateTest.kt
new file mode 100644
index 00000000000..47457f7ad68
--- /dev/null
+++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchKeyboardStateTest.kt
@@ -0,0 +1,94 @@
+package au.com.shiftyjelly.pocketcasts.search
+
+import android.view.KeyEvent.KEYCODE_DPAD_DOWN
+import android.view.KeyEvent.KEYCODE_DPAD_LEFT
+import android.view.KeyEvent.KEYCODE_DPAD_RIGHT
+import android.view.KeyEvent.KEYCODE_DPAD_UP
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class TvSearchKeyboardStateTest {
+
+ private val state = TvSearchKeyboardState()
+
+ @Test
+ fun `initial state selects the first letter on the letters page`() {
+ assertFalse(state.isSymbolsPage)
+ assertEquals(TvSearchKey.Character('a'), state.selectedKey)
+ }
+
+ @Test
+ fun `dpad right moves to the next key and consumes the event`() {
+ val consumed = state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true)
+ assertTrue(consumed)
+ assertEquals(TvSearchKey.Character('b'), state.selectedKey)
+ }
+
+ @Test
+ fun `dpad left walks onto the leading Space and Toggle keys then stops`() {
+ state.handleDpadDirection(KEYCODE_DPAD_LEFT, isKeyDown = true)
+ assertEquals(TvSearchKey.Space, state.selectedKey)
+ state.handleDpadDirection(KEYCODE_DPAD_LEFT, isKeyDown = true)
+ assertEquals(TvSearchKey.TogglePage, state.selectedKey)
+
+ val consumedAtStart = state.handleDpadDirection(KEYCODE_DPAD_LEFT, isKeyDown = true)
+ assertFalse(consumedAtStart)
+ assertEquals(TvSearchKey.TogglePage, state.selectedKey)
+ }
+
+ @Test
+ fun `dpad right stops on the trailing Delete key`() {
+ repeat(40) { state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true) }
+ assertEquals(TvSearchKey.Delete, state.selectedKey)
+
+ val consumedAtEnd = state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true)
+ assertFalse(consumedAtEnd)
+ assertEquals(TvSearchKey.Delete, state.selectedKey)
+ }
+
+ @Test
+ fun `key up returns the consumed state then resets`() {
+ state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true)
+ assertTrue(state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = false))
+ assertFalse(state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = false))
+ }
+
+ @Test
+ fun `toggle page swaps to the symbols page and back`() {
+ state.togglePage()
+ assertTrue(state.isSymbolsPage)
+ assertEquals(TvSearchKey.Character('0'), state.selectedKey)
+
+ state.togglePage()
+ assertFalse(state.isSymbolsPage)
+ assertEquals(TvSearchKey.Character('a'), state.selectedKey)
+ }
+
+ @Test
+ fun `toggle page clamps a high selection onto the shorter symbols page`() {
+ assertEquals(29, state.keys.size)
+ repeat(40) { state.handleDpadDirection(KEYCODE_DPAD_RIGHT, isKeyDown = true) }
+ assertEquals(TvSearchKey.Delete, state.selectedKey)
+
+ state.togglePage()
+ assertTrue(state.isSymbolsPage)
+ assertEquals(20, state.keys.size)
+ assertEquals(TvSearchKey.Delete, state.selectedKey)
+ }
+
+ @Test
+ fun `vertical dpad keys are not consumed so navigation can leave the keyboard`() {
+ assertFalse(state.handleDpadDirection(KEYCODE_DPAD_UP, isKeyDown = true))
+ assertFalse(state.handleDpadDirection(KEYCODE_DPAD_DOWN, isKeyDown = true))
+ }
+
+ @Test
+ fun `isSelected only reports the selection while focused`() {
+ assertFalse(state.isSelected(state.selectedIndex))
+ state.onFocusChanged(true)
+ assertTrue(state.isSelected(state.selectedIndex))
+ assertFalse(state.isSelected(state.selectedIndex + 1))
+ }
+}
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
new file mode 100644
index 00000000000..b48faba893c
--- /dev/null
+++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/search/TvSearchViewModelTest.kt
@@ -0,0 +1,251 @@
+package au.com.shiftyjelly.pocketcasts.search
+
+import android.content.Context
+import android.content.res.Resources
+import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverFeedLoader
+import au.com.shiftyjelly.pocketcasts.discover.TvDiscoverRow
+import au.com.shiftyjelly.pocketcasts.preferences.Settings
+import au.com.shiftyjelly.pocketcasts.preferences.UserSetting
+import au.com.shiftyjelly.pocketcasts.repositories.lists.ListRepository
+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.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.ExpandedStyle
+import au.com.shiftyjelly.pocketcasts.servers.model.ListFeed
+import au.com.shiftyjelly.pocketcasts.servers.model.ListType
+import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class TvSearchViewModelTest {
+
+ @get:Rule
+ val coroutineRule = MainCoroutineRule()
+
+ private val listRepository = mock()
+ private val syncManager = mock {
+ on { isLoggedIn() } doReturn false
+ }
+ private val resources = mock()
+ private val context = mock {
+ whenever(it.resources).thenReturn(resources)
+ }
+ private val discoverCountryCode = mock> {
+ whenever(it.value).thenReturn("us")
+ }
+ private val settings = mock {
+ whenever(it.discoverCountryCode).thenReturn(discoverCountryCode)
+ }
+
+ @Test
+ fun `exposes the browse categories from the search feed row`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(
+ discover(categoriesRow(source = "https://categories.json")),
+ )
+ whenever(listRepository.getCategoriesList(eq("https://categories.json")))
+ .thenReturn(listOf(category(1, "Comedy"), category(2, "True Crime")))
+
+ val viewModel = createViewModel()
+
+ assertEquals(listOf("Comedy", "True Crime"), viewModel.categories.value.map { it.name })
+ }
+
+ @Test
+ fun `categories are empty when the feed has no categories row`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(
+ discover(row(id = "trending", title = "Trending", source = "https://lists/trending.json")),
+ )
+
+ val viewModel = createViewModel()
+
+ assertTrue(viewModel.categories.value.isEmpty())
+ }
+
+ @Test
+ fun `categories are empty when the categories request fails`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(
+ discover(categoriesRow(source = "https://categories.json")),
+ )
+ whenever(listRepository.getCategoriesList(any())).thenThrow(RuntimeException("Network error"))
+
+ val viewModel = createViewModel()
+
+ assertTrue(viewModel.categories.value.isEmpty())
+ }
+
+ @Test
+ fun `exposes the loaded discover rows`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(
+ discover(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()
+
+ val rows = viewModel.discoverRows.value
+ assertEquals(listOf("trending"), rows.map { it.id })
+ assertTrue(rows.single() is TvDiscoverRow.Podcasts)
+ }
+
+ @Test
+ fun `authenticated rows are dropped when logged out`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(
+ discover(
+ row(id = "public", title = "Public", source = "https://lists/public.json"),
+ row(id = "members", title = "Members", source = "https://lists/members.json", authenticated = true),
+ ),
+ )
+ whenever(listRepository.getListFeed(eq("https://lists/public.json"), any()))
+ .thenReturn(podcastFeed("podcast-public"))
+
+ val viewModel = createViewModel()
+
+ assertEquals(listOf("public"), viewModel.discoverRows.value.map { it.id })
+ }
+
+ @Test
+ fun `single podcast display style maps to a single podcast row`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(
+ discover(
+ row(
+ id = "spotlight",
+ title = "Spotlight",
+ source = "https://lists/spotlight.json",
+ displayStyle = DisplayStyle.SinglePodcast(),
+ ),
+ ),
+ )
+ whenever(listRepository.getListFeed(eq("https://lists/spotlight.json"), any()))
+ .thenReturn(podcastFeed("podcast-spotlight"))
+
+ val viewModel = createViewModel()
+
+ assertTrue(viewModel.discoverRows.value.single() is TvDiscoverRow.SinglePodcast)
+ }
+
+ @Test
+ fun `discover rows are empty when loading fails`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenThrow(RuntimeException("Network error"))
+
+ val viewModel = createViewModel()
+
+ assertTrue(viewModel.discoverRows.value.isEmpty())
+ }
+
+ @Test
+ fun `categories still load when building the discover rows fails`() = runTest {
+ whenever(listRepository.getSearchDiscoverFeed()).thenReturn(
+ Discover(
+ layout = listOf(categoriesRow(source = "https://categories.json")),
+ regions = emptyMap(),
+ regionCodeToken = "[regionCode]",
+ regionNameToken = "[regionName]",
+ defaultRegionCode = "us",
+ ),
+ )
+ whenever(listRepository.getCategoriesList(eq("https://categories.json")))
+ .thenReturn(listOf(category(1, "Comedy")))
+
+ val viewModel = createViewModel()
+
+ assertEquals(listOf("Comedy"), viewModel.categories.value.map { it.name })
+ assertTrue(viewModel.discoverRows.value.isEmpty())
+ }
+
+ private fun createViewModel() = TvSearchViewModel(
+ discoverFeedLoader = TvDiscoverFeedLoader(
+ listRepository = listRepository,
+ settings = settings,
+ context = context,
+ ),
+ syncManager = syncManager,
+ )
+
+ private fun category(id: Int, name: String) = DiscoverCategory(id = id, name = name, icon = "", source = "")
+
+ private fun discover(vararg rows: DiscoverRow) = Discover(
+ layout = rows.toList(),
+ regions = mapOf("us" to DiscoverRegion(name = "United States", flag = "flag", code = "us")),
+ regionCodeToken = "[regionCode]",
+ regionNameToken = "[regionName]",
+ defaultRegionCode = "us",
+ )
+
+ private fun row(
+ id: String,
+ title: String,
+ source: String,
+ type: ListType = ListType.PodcastList,
+ displayStyle: DisplayStyle = DisplayStyle.SmallList(),
+ authenticated: Boolean = false,
+ ) = DiscoverRow(
+ id = id,
+ type = type,
+ displayStyle = displayStyle,
+ expandedStyle = ExpandedStyle.PlainList(),
+ expandedTopItemLabel = null,
+ title = title,
+ source = source,
+ listUuid = id,
+ categoryId = null,
+ regions = listOf("us"),
+ curated = false,
+ sponsored = false,
+ authenticated = authenticated,
+ mostPopularCategoriesId = null,
+ sponsoredCategoryIds = null,
+ )
+
+ private fun categoriesRow(source: String) = row(
+ id = "categories",
+ title = "Browse By Category",
+ source = source,
+ type = ListType.Categories,
+ )
+
+ private fun podcastFeed(vararg podcastUuids: String) = ListFeed(
+ title = null,
+ subtitle = null,
+ description = null,
+ shortDescription = null,
+ date = null,
+ podcasts = podcastUuids.map { uuid ->
+ DiscoverPodcast(
+ uuid = uuid,
+ title = "Podcast $uuid",
+ url = null,
+ author = null,
+ category = null,
+ description = null,
+ language = null,
+ mediaType = null,
+ )
+ },
+ episodes = null,
+ podroll = null,
+ collectionImageUrl = null,
+ collectionRectangleImageUrl = null,
+ featureImage = null,
+ headerImageUrl = null,
+ tintColors = null,
+ collageImages = null,
+ webLinkUrl = null,
+ webLinkTitle = null,
+ promotion = null,
+ )
+}