diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt index f11ff0d8c6..19025262e0 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -39,6 +39,8 @@ import androidx.tv.material3.NavigationDrawer import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.Text import com.example.jetcaster.tv.ui.discover.DiscoverScreen +import com.example.jetcaster.tv.ui.episode.EpisodeScreen +import com.example.jetcaster.tv.ui.episode.EpisodeScreenViewModel import com.example.jetcaster.tv.ui.library.LibraryScreen import com.example.jetcaster.tv.ui.podcast.PodcastScreen import com.example.jetcaster.tv.ui.podcast.PodcastScreenViewModel @@ -124,6 +126,9 @@ private fun Route(jetcasterAppState: JetcasterAppState) { showPodcastDetails = { jetcasterAppState.showPodcastDetails(it.uri) }, + showEpisodeDetails = { + jetcasterAppState.showEpisodeDetails(it.episode.uri) + }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) .fillMaxSize() @@ -138,6 +143,9 @@ private fun Route(jetcasterAppState: JetcasterAppState) { showPodcastDetails = { jetcasterAppState.showPodcastDetails(it.podcast.uri) }, + showEpisodeDetails = { + jetcasterAppState.showEpisodeDetails(it.episode.uri) + }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) .fillMaxSize() @@ -164,12 +172,26 @@ private fun Route(jetcasterAppState: JetcasterAppState) { podcastScreenViewModel = podcastScreenViewModel, backToHomeScreen = jetcasterAppState::navigateToDiscover, playEpisode = {}, + showEpisodeDetails = { jetcasterAppState.showEpisodeDetails(it.episode.uri) }, modifier = Modifier - .padding(JetcasterAppDefaults.overScanMargin.podcastDetails.intoPaddingValues()) + .padding(JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues()) .fillMaxSize(), ) } + composable(Screen.Episode.route) { + val episodeScreenViewModel: EpisodeScreenViewModel = viewModel( + factory = EpisodeScreenViewModel.factory + ) + EpisodeScreen( + playEpisode = { + jetcasterAppState.playEpisode(it.uri) + }, + backToHome = jetcasterAppState::navigateToDiscover, + episodeScreenViewModel = episodeScreenViewModel, + ) + } + composable(Screen.Player.route) { Text(text = "Player") } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt index 23ac10a355..a4153a8335 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt @@ -51,6 +51,12 @@ class JetcasterAppState( navHostController.navigate(screen.route) } + fun showEpisodeDetails(episodeUri: String) { + val encodeUrl = Uri.encode(episodeUri) + val screen = Screen.Episode(encodeUrl) + navHostController.navigate(screen.route) + } + fun playEpisode(episodeUri: String) { val screen = Screen.Player(episodeUri) navHostController.navigate(screen.route) @@ -97,7 +103,17 @@ sealed interface Screen { companion object : Screen { private const val ROOT = "podcast" - private const val PARAMETER_NAME = "podcastUri" + const val PARAMETER_NAME = "podcastUri" + override val route = "$ROOT/{$PARAMETER_NAME}" + } + } + + data class Episode(private val episodeUri: String) : Screen { + + override val route: String = "$ROOT/$episodeUri" + companion object : Screen { + private const val ROOT = "episode" + const val PARAMETER_NAME = "episodeUri" override val route = "$ROOT/{$PARAMETER_NAME}" } } @@ -107,7 +123,7 @@ sealed interface Screen { companion object : Screen { private const val ROOT = "player" - private const val PARAMETER_NAME = "episodeUri" + const val PARAMETER_NAME = "episodeUri" override val route = "$ROOT/{$PARAMETER_NAME}" } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt new file mode 100644 index 0000000000..9c339d2cff --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Background.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.ContentScale +import coil.compose.AsyncImage +import com.example.jetcaster.core.data.database.model.Podcast + +@Composable +internal fun Background( + podcast: Podcast, + modifier: Modifier = Modifier, + overlay: DrawScope.() -> Unit = { + val brush = Brush.radialGradient( + listOf(Color.Black, Color.Transparent), + center = Offset(0f, size.height), + radius = size.width * 1.5f + ) + drawRect(brush, blendMode = BlendMode.Multiply) + } +) { + AsyncImage( + model = podcast.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + .fillMaxWidth() + .drawWithCache { + onDrawWithContent { + drawContent() + overlay() + } + } + ) +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt new file mode 100644 index 0000000000..aef3c09787 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Button.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.IconButton +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun PlayButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) = + ButtonWithIcon( + icon = Icons.Outlined.PlayArrow, + label = stringResource(R.string.label_play), + onClick = onClick, + modifier = modifier + ) + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun EnqueueButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton(onClick = onClick, modifier = modifier) { + Icon( + Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = stringResource(R.string.label_add_playlist), + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun ButtonWithIcon( + icon: ImageVector, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) = + Button(onClick = onClick, modifier = modifier) { + Icon( + icon, + contentDescription = null, + modifier = Modifier + .width(ButtonDefaults.IconSize) + .padding(end = ButtonDefaults.IconSpacing) + ) + Text(text = label) + } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt index c3d0487d1d..b9b90a84d3 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -26,14 +26,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.TvLazyListState import androidx.tv.foundation.lazy.list.TvLazyRow import androidx.tv.foundation.lazy.list.items +import androidx.tv.foundation.lazy.list.rememberTvLazyListState import androidx.tv.material3.Card import androidx.tv.material3.CardScale import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -55,14 +59,17 @@ internal fun Catalog( podcastList: PodcastList, latestEpisodeList: EpisodeList, onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, + state: TvLazyListState = rememberTvLazyListState(), header: (@Composable () -> Unit)? = null, ) { TvLazyColumn( modifier = modifier, contentPadding = JetcasterAppDefaults.overScanMargin.catalog.intoPaddingValues(), verticalArrangement = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) + Arrangement.spacedBy(JetcasterAppDefaults.gap.section), + state = state, ) { if (header != null) { item { header() } @@ -77,13 +84,14 @@ internal fun Catalog( item { LatestEpisodeSection( episodeList = latestEpisodeList, - onEpisodeSelected = {}, + onEpisodeSelected = onEpisodeSelected, title = stringResource(R.string.label_latest_episode) ) } } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun PodcastSection( podcastList: PodcastList, @@ -95,10 +103,15 @@ private fun PodcastSection( title = title, modifier = modifier ) { - PodcastRow(podcastList = podcastList, onPodcastSelected = onPodcastSelected) + PodcastRow( + podcastList = podcastList, + onPodcastSelected = onPodcastSelected, + modifier = Modifier.focusRestorer() + ) } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun LatestEpisodeSection( episodeList: EpisodeList, @@ -110,7 +123,11 @@ private fun LatestEpisodeSection( modifier = modifier, title = title ) { - EpisodeRow(episodeList = episodeList, onEpisodeSelected = onEpisodeSelected) + EpisodeRow( + episodeList = episodeList, + onEpisodeSelected = onEpisodeSelected, + modifier = Modifier.focusRestorer() + ) } } @@ -141,7 +158,7 @@ private fun PodcastRow( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), horizontalArrangement: Arrangement.Horizontal = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), ) { TvLazyRow( contentPadding = contentPadding, @@ -189,7 +206,7 @@ private fun EpisodeRow( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), horizontalArrangement: Arrangement.Horizontal = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + Arrangement.spacedBy(JetcasterAppDefaults.gap.episodeRow), ) { TvLazyRow( contentPadding = contentPadding, @@ -261,7 +278,7 @@ private fun EpisodeMetaData(episode: EpisodeToPodcast, modifier: Modifier = Modi Text(text = episode.podcast.title, style = MaterialTheme.typography.bodySmall) if (duration != null) { Spacer( - modifier = Modifier.height(JetcasterAppDefaults.gapSettings.catalogItemGap * 0.8f) + modifier = Modifier.height(JetcasterAppDefaults.gap.podcastRow * 0.8f) ) EpisodeDataAndDuration(offsetDateTime = publishedDate, duration = duration) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt index 8e655a35b2..f70e6b3557 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt @@ -53,7 +53,7 @@ fun ErrorState( Button( onClick = backToHome, modifier - .padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + .padding(top = JetcasterAppDefaults.gap.podcastRow) .focusRequester(focusRequester) ) { Text(text = stringResource(R.string.label_back_to_home)) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt new file mode 100644 index 0000000000..1f74b61aa9 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Thumbnail.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.component + +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.example.jetcaster.core.data.database.model.Podcast +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun Thumbnail( + podcast: Podcast, + modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape(12.dp), + size: DpSize = DpSize( + JetcasterAppDefaults.cardWidth.medium, + JetcasterAppDefaults.cardWidth.medium + ), + contentScale: ContentScale = ContentScale.Crop +) = + AsyncImage( + model = podcast.imageUrl, + contentDescription = null, + contentScale = contentScale, + modifier = Modifier + .size(size) + .clip(shape) + .then(modifier) + ) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt index 3d84a42a6a..2f5548b2e7 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -26,13 +26,18 @@ import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.foundation.lazy.list.TvLazyListState +import androidx.tv.foundation.lazy.list.rememberTvLazyListState import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Tab import androidx.tv.material3.TabRow import androidx.tv.material3.Text import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.database.model.Podcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.tv.model.CategoryList @@ -45,6 +50,7 @@ import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable fun DiscoverScreen( showPodcastDetails: (Podcast) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, discoverScreenViewModel: DiscoverScreenViewModel = viewModel() ) { @@ -67,6 +73,7 @@ fun DiscoverScreen( latestEpisodeList = s.latestEpisodeList, onPodcastSelected = { showPodcastDetails(it.podcast) }, onCategorySelected = discoverScreenViewModel::selectCategory, + onEpisodeSelected = showEpisodeDetails, modifier = Modifier .fillMaxSize() .then(modifier) @@ -83,34 +90,60 @@ private fun CatalogWithCategorySelection( selectedCategory: Category, latestEpisodeList: EpisodeList, onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, onCategorySelected: (Category) -> Unit, modifier: Modifier = Modifier, + state: TvLazyListState = rememberTvLazyListState(), ) { - val tabRow = remember(categoryList) { FocusRequester() } - + val (focusRequester, selectedTab) = remember { + FocusRequester.createRefs() + } LaunchedEffect(Unit) { - tabRow.requestFocus() + focusRequester.requestFocus() } + val selectedTabIndex = categoryList.indexOf(selectedCategory) Catalog( podcastList = podcastList, latestEpisodeList = latestEpisodeList, - onPodcastSelected = onPodcastSelected, - modifier = modifier, + onPodcastSelected = { + focusRequester.saveFocusedChild() + onPodcastSelected(it) + }, + onEpisodeSelected = { + focusRequester.saveFocusedChild() + onEpisodeSelected(it) + }, + modifier = modifier + .focusRequester(focusRequester) + .focusRestorer(), + state = state, ) { + TabRow( - selectedTabIndex = categoryList.indexOf(selectedCategory), - modifier = Modifier.focusRequester(tabRow) + selectedTabIndex = selectedTabIndex, + modifier = Modifier.focusProperties { + enter = { + selectedTab + } + } ) { - categoryList.forEach { + categoryList.forEachIndexed { index, category -> + val tabModifier = if (selectedTabIndex == index) { + Modifier.focusRequester(selectedTab) + } else { + Modifier + } + Tab( - selected = it == selectedCategory, + selected = index == selectedTabIndex, onFocus = { - onCategorySelected(it) - } + onCategorySelected(category) + }, + modifier = tabModifier, ) { Text( - text = it.name, + text = category.name, modifier = Modifier.padding(JetcasterAppDefaults.padding.tab) ) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt new file mode 100644 index 0000000000..76bdba6d50 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreen.kt @@ -0,0 +1,154 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.episode + +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.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.tv.ui.component.Background +import com.example.jetcaster.tv.ui.component.EnqueueButton +import com.example.jetcaster.tv.ui.component.EpisodeDataAndDuration +import com.example.jetcaster.tv.ui.component.ErrorState +import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.component.PlayButton +import com.example.jetcaster.tv.ui.component.Thumbnail +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults + +@Composable +fun EpisodeScreen( + playEpisode: (Episode) -> Unit, + backToHome: () -> Unit, + modifier: Modifier = Modifier, + episodeScreenViewModel: EpisodeScreenViewModel = viewModel() +) { + + val uiState by episodeScreenViewModel.uiStateFlow.collectAsState() + + when (val s = uiState) { + EpisodeScreenUiState.Loading -> Loading(modifier = modifier) + EpisodeScreenUiState.Error -> ErrorState(backToHome = backToHome, modifier = modifier) + is EpisodeScreenUiState.Ready -> EpisodeDetailsWithBackground( + episodeToPodcast = s.episodeToPodcast, + playEpisode = playEpisode, + addPlayList = episodeScreenViewModel::addPlayList + ) + } +} + +@Composable +private fun EpisodeDetailsWithBackground( + episodeToPodcast: EpisodeToPodcast, + playEpisode: (Episode) -> Unit, + addPlayList: (Episode) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Background(podcast = episodeToPodcast.podcast, modifier = Modifier.fillMaxSize()) + EpisodeDetails( + episodeToPodcast = episodeToPodcast, + playEpisode = playEpisode, + addPlayList = addPlayList, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.episode.intoPaddingValues()) + ) + } +} + +@Composable +private fun EpisodeDetails( + episodeToPodcast: EpisodeToPodcast, + playEpisode: (Episode) -> Unit, + addPlayList: (Episode) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn), + ) { + Thumbnail( + podcast = episodeToPodcast.podcast, + size = JetcasterAppDefaults.thumbnailSize.episode + ) + EpisodeInfo( + episode = episodeToPodcast.episode, + playEpisode = playEpisode, + addPlayList = addPlayList, + modifier = Modifier.weight(1f) + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeInfo( + episode: Episode, + playEpisode: (Episode) -> Unit, + addPlayList: (Episode) -> Unit, + modifier: Modifier = Modifier +) { + val author = episode.author + val duration = episode.duration + val summary = episode.summary + + Column(modifier) { + if (author != null) { + Text(text = author, style = MaterialTheme.typography.bodySmall) + } + Text(text = episode.title, style = MaterialTheme.typography.headlineLarge) + if (duration != null) { + EpisodeDataAndDuration(offsetDateTime = episode.published, duration = duration) + } + if (summary != null) { + Spacer(modifier = Modifier.height(JetcasterAppDefaults.gap.paragraph)) + Text(text = summary, softWrap = true, maxLines = 5, overflow = TextOverflow.Ellipsis) + } + Spacer(modifier = Modifier.height(JetcasterAppDefaults.gap.paragraph)) + Controls(playEpisode = { playEpisode(episode) }, addPlayList = { addPlayList(episode) }) + } +} + +@Composable +private fun Controls( + playEpisode: () -> Unit, + addPlayList: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), + modifier = modifier + ) { + PlayButton(onClick = playEpisode) + EnqueueButton(onClick = addPlayList) + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt new file mode 100644 index 0000000000..e3bacc7480 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.jetcaster.tv.ui.episode + +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.Episode +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.di.Graph +import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.tv.ui.Screen +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class EpisodeScreenViewModel( + handle: SavedStateHandle, + podcastsRepository: PodcastsRepository = Graph.podcastRepository, + episodeStore: EpisodeStore = Graph.episodeStore, +) : ViewModel() { + + private val episodeUri = handle.get(Screen.Episode.PARAMETER_NAME) + + private val episodeToPodcastFlow = if (episodeUri != null) { + episodeStore.episodeAndPodcastWithUri(episodeUri) + } else { + flowOf(null) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + null + ) + + val uiStateFlow = episodeToPodcastFlow.map { + if (it != null) { + EpisodeScreenUiState.Ready(it) + } else { + EpisodeScreenUiState.Error + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + EpisodeScreenUiState.Loading + ) + + fun addPlayList(episode: Episode) { + } + + init { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } + + companion object { + @Suppress("UNCHECKED_CAST") + val factory = object : AbstractSavedStateViewModelFactory() { + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T { + return EpisodeScreenViewModel( + handle + ) as T + } + } + } +} + +sealed interface EpisodeScreenUiState { + data object Loading : EpisodeScreenUiState + data object Error : EpisodeScreenUiState + data class Ready(val episodeToPodcast: EpisodeToPodcast) : EpisodeScreenUiState +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt index 4f77143de5..bdbf1b5625 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -25,17 +25,22 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.res.stringResource import androidx.lifecycle.viewmodel.compose.viewModel import androidx.tv.material3.Button import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList import com.example.jetcaster.tv.ui.component.Catalog import com.example.jetcaster.tv.ui.component.Loading import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @@ -45,6 +50,7 @@ fun LibraryScreen( modifier: Modifier = Modifier, navigateToDiscover: () -> Unit, showPodcastDetails: (PodcastWithExtraInfo) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, libraryScreenViewModel: LibraryScreenViewModel = viewModel() ) { val uiState by libraryScreenViewModel.uiState.collectAsState() @@ -54,15 +60,41 @@ fun LibraryScreen( NavigateToDiscover(onNavigationRequested = navigateToDiscover, modifier = modifier) } - is LibraryScreenUiState.Ready -> Catalog( + is LibraryScreenUiState.Ready -> Library( podcastList = s.subscribedPodcastList, - latestEpisodeList = s.latestEpisodeList, - onPodcastSelected = showPodcastDetails, - modifier = modifier + episodeList = s.latestEpisodeList, + showPodcastDetails = showPodcastDetails, + showEpisodeDetails = showEpisodeDetails, + modifier = modifier, ) } } +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun Library( + podcastList: PodcastList, + episodeList: EpisodeList, + showPodcastDetails: (PodcastWithExtraInfo) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() }, +) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Catalog( + podcastList = podcastList, + latestEpisodeList = episodeList, + onPodcastSelected = showPodcastDetails, + onEpisodeSelected = showEpisodeDetails, + modifier = modifier + .focusRequester(focusRequester) + .focusRestorer() + ) +} + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun NavigateToDiscover( @@ -83,7 +115,7 @@ private fun NavigateToDiscover( Button( onClick = onNavigationRequested, modifier = Modifier - .padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + .padding(top = JetcasterAppDefaults.gap.podcastRow) .focusRequester(focusRequester) ) { Text(text = stringResource(id = R.string.label_navigate_to_discover)) diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt index 6d22f685d1..6f3f21d396 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt @@ -21,12 +21,9 @@ 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.aspectRatio -import androidx.compose.foundation.layout.fillMaxWidth +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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Remove @@ -35,18 +32,12 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -57,16 +48,17 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import coil.compose.AsyncImage import com.example.jetcaster.core.data.database.model.Episode import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.database.model.Podcast import com.example.jetcaster.tv.R import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.ui.component.Background import com.example.jetcaster.tv.ui.component.ButtonWithIcon import com.example.jetcaster.tv.ui.component.EpisodeDataAndDuration import com.example.jetcaster.tv.ui.component.ErrorState import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.component.Thumbnail import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable @@ -74,6 +66,7 @@ fun PodcastScreen( podcastScreenViewModel: PodcastScreenViewModel, backToHomeScreen: () -> Unit, playEpisode: (Episode) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, ) { val uiState by podcastScreenViewModel.uiStateFlow.collectAsState() @@ -87,7 +80,7 @@ fun PodcastScreen( subscribe = podcastScreenViewModel::subscribe, unsubscribe = podcastScreenViewModel::unsubscribe, playEpisode = playEpisode, - modifier = modifier, + showEpisodeDetails = showEpisodeDetails, ) } } @@ -100,10 +93,11 @@ private fun PodcastDetailsWithBackground( subscribe: (Podcast, Boolean) -> Unit, unsubscribe: (Podcast, Boolean) -> Unit, playEpisode: (Episode) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() } ) { - Box { + Box(modifier = modifier) { Background(podcast = podcast) PodcastDetails( podcast = podcast, @@ -113,12 +107,15 @@ private fun PodcastDetailsWithBackground( unsubscribe = unsubscribe, playEpisode = playEpisode, focusRequester = focusRequester, - modifier = modifier + showEpisodeDetails = showEpisodeDetails, + modifier = Modifier + .fillMaxSize() + .padding(JetcasterAppDefaults.overScanMargin.podcast.intoPaddingValues()) ) } } -@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun PodcastDetails( podcast: Podcast, @@ -127,13 +124,14 @@ private fun PodcastDetails( subscribe: (Podcast, Boolean) -> Unit, unsubscribe: (Podcast, Boolean) -> Unit, playEpisode: (Episode) -> Unit, + showEpisodeDetails: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, focusRequester: FocusRequester = remember { FocusRequester() } ) { Row( modifier = modifier, horizontalArrangement = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) + Arrangement.spacedBy(JetcasterAppDefaults.gap.twoColumn) ) { PodcastInfo( podcast = podcast, @@ -145,6 +143,7 @@ private fun PodcastDetails( PodcastEpisodeList( episodeList = episodeList, onEpisodeSelected = { playEpisode(it.episode) }, + onDetailsRequested = showEpisodeDetails, modifier = Modifier .focusRequester(focusRequester) .focusRestorer() @@ -157,31 +156,6 @@ private fun PodcastDetails( } } -@Composable -private fun Background( - podcast: Podcast, - modifier: Modifier = Modifier, -) { - AsyncImage( - model = podcast.imageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = modifier - .fillMaxWidth() - .drawWithCache { - val overlay = Brush.radialGradient( - listOf(Color.Black, Color.Transparent), - center = Offset(0f, size.height), - radius = size.width * 1.5f - ) - onDrawWithContent { - drawContent() - drawRect(overlay, blendMode = BlendMode.Multiply) - } - } - ) -} - @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun PodcastInfo( @@ -195,17 +169,7 @@ private fun PodcastInfo( val description = podcast.description Column(modifier = modifier) { - AsyncImage( - model = podcast.imageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .width(JetcasterAppDefaults.cardWidth.medium) - .aspectRatio(1f) - .clip( - RoundedCornerShape(12.dp) - ) - ) + Thumbnail(podcast = podcast) Spacer(modifier = Modifier.height(16.dp)) if (author != null) { Text( @@ -231,7 +195,7 @@ private fun PodcastInfo( subscribe, unsubscribe, modifier = Modifier - .padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + .padding(top = JetcasterAppDefaults.gap.podcastRow) ) } } @@ -273,14 +237,19 @@ private fun ToggleSubscriptionButton( private fun PodcastEpisodeList( episodeList: EpisodeList, onEpisodeSelected: (EpisodeToPodcast) -> Unit, + onDetailsRequested: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier ) { TvLazyColumn( - verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), modifier = modifier ) { items(episodeList) { - EpisodeListItem(episodeToPodcast = it, onEpisodeSelected = onEpisodeSelected) + EpisodeListItem( + episodeToPodcast = it, + onEpisodeSelected = onEpisodeSelected, + onInfoClicked = onDetailsRequested + ) } } } @@ -290,16 +259,19 @@ private fun PodcastEpisodeList( private fun EpisodeListItem( episodeToPodcast: EpisodeToPodcast, onEpisodeSelected: (EpisodeToPodcast) -> Unit, + onInfoClicked: (EpisodeToPodcast) -> Unit, modifier: Modifier = Modifier, selected: Boolean = false ) { ListItem( selected = selected, - onClick = { onEpisodeSelected(episodeToPodcast) }, + onClick = { onInfoClicked(episodeToPodcast) }, + onLongClick = { onEpisodeSelected(episodeToPodcast) }, modifier = modifier ) { Row( - modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 16.dp) + modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically ) { EpisodeMetaData(episode = episodeToPodcast.episode) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt index 5ea8d84690..8b39200abd 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt @@ -25,6 +25,7 @@ import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.ui.Screen import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -40,9 +41,13 @@ class PodcastScreenViewModel( episodeStore: EpisodeStore = Graph.episodeStore, ) : ViewModel() { - private val podcastUri = handle.get("podcastUri") ?: "uri://no/podcast/is/specified" + private val podcastUri = handle.get(Screen.Podcast.PARAMETER_NAME) - private val podcastFlow = podcastStore.podcastWithUri(podcastUri).stateIn( + private val podcastFlow = if (podcastUri != null) { + podcastStore.podcastWithUri(podcastUri) + } else { + flowOf(null) + }.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5_000), null diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt index 304b8429b3..67295c7537 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt @@ -162,7 +162,7 @@ private fun Controls( } Column( - verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.itemGap), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.item), modifier = modifier ) { KeywordInput( @@ -232,8 +232,8 @@ private fun CategorySelection( ) { FlowRow( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.chipGap), - verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.chipGap), + horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.chip), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.chip), ) { categorySelectionList.forEach { FilterChip( @@ -262,8 +262,8 @@ private fun SearchResult( TvLazyVerticalGrid( columns = TvGridCells.Fixed(4), horizontalArrangement = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), - verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gap.podcastRow), modifier = modifier, ) { item(span = { TvGridItemSpan(maxLineSpan) }) { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt index 538d191885..fda021d68c 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt @@ -18,20 +18,28 @@ package com.example.jetcaster.tv.ui.theme import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp internal data object JetcasterAppDefaults { val overScanMargin = OverScanMarginSettings() - val gapSettings = GapSettings() + val gap = GapSettings() val cardWidth = CardWidth() val padding = PaddingSettings() + val thumbnailSize = ThumbnailSize() } internal data class OverScanMarginSettings( val default: OverScanMargin = OverScanMargin(), - val podcastDetails: OverScanMargin = OverScanMargin(top = 40.dp, bottom = 40.dp), + val catalog: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp), + val episode: OverScanMargin = OverScanMargin(start = 80.dp, end = 80.dp), val drawer: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp), - val catalog: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp) + val podcast: OverScanMargin = OverScanMargin( + top = 40.dp, + bottom = 40.dp, + start = 80.dp, + end = 80.dp + ), ) internal data class OverScanMargin( @@ -51,14 +59,21 @@ internal data class CardWidth( val small: Dp = 124.dp ) +internal data class ThumbnailSize( + val episode: DpSize = DpSize(266.dp, 266.dp), +) + internal data class PaddingSettings( val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp) ) internal data class GapSettings( - val catalogItemGap: Dp = 20.dp, - val catalogSectionGap: Dp = 40.dp, - val itemGap: Dp = 16.dp, - val chipGap: Dp = 8.dp + val chip: Dp = 8.dp, + val episodeRow: Dp = 20.dp, + val item: Dp = 16.dp, + val paragraph: Dp = 16.dp, + val podcastRow: Dp = 20.dp, + val section: Dp = 40.dp, + val twoColumn: Dp = 40.dp, ) diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml index 453c63048d..8baaeeac10 100644 --- a/Jetcaster/tv-app/src/main/res/values/strings.xml +++ b/Jetcaster/tv-app/src/main/res/values/strings.xml @@ -39,6 +39,7 @@ Discover the podcasts Back to Home Search podcasts by keyword + Add to playlist Updated a while ago