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, + ) +}