From 7c5e52176a02f2e9feaffef32b6ded898ee57bd0 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Thu, 21 Mar 2024 16:05:35 +0900 Subject: [PATCH 1/7] Set the navigation graph to the tv-app --- Jetcaster/tv-app/build.gradle.kts | 3 + Jetcaster/tv-app/src/main/AndroidManifest.xml | 2 - .../com/example/jetcaster/tv/MainActivity.kt | 3 +- .../example/jetcaster/tv/ui/JetcasterApp.kt | 177 ++++++++++++++++++ .../jetcaster/tv/ui/JetcasterAppState.kt | 110 +++++++++++ .../tv/ui/component/NotAvailableFeature.kt | 33 ++++ .../tv/ui/discover/DiscoverScreen.kt | 26 +++ .../jetcaster/tv/ui/library/LibraryScreen.kt | 28 +++ .../jetcaster/tv/ui/profile/ProfileScreen.kt | 26 +++ .../jetcaster/tv/ui/search/SearchScreen.kt | 26 +++ .../tv/ui/settings/SettingsScreen.kt | 28 +++ .../tv-app/src/main/res/values/strings.xml | 1 + 12 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/NotAvailableFeature.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/profile/ProfileScreen.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/settings/SettingsScreen.kt diff --git a/Jetcaster/tv-app/build.gradle.kts b/Jetcaster/tv-app/build.gradle.kts index 5d1c536833..279be42441 100644 --- a/Jetcaster/tv-app/build.gradle.kts +++ b/Jetcaster/tv-app/build.gradle.kts @@ -70,11 +70,14 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.material.iconsExtended) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.tv.foundation) implementation(libs.androidx.tv.material) implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation.compose) + implementation(project(":core")) implementation(project(":designsystem")) diff --git a/Jetcaster/tv-app/src/main/AndroidManifest.xml b/Jetcaster/tv-app/src/main/AndroidManifest.xml index 6029cbc7c4..dac79d860d 100644 --- a/Jetcaster/tv-app/src/main/AndroidManifest.xml +++ b/Jetcaster/tv-app/src/main/AndroidManifest.xml @@ -36,8 +36,6 @@ android:exported="true"> - - diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt index eaf5a7f40f..ec52101124 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/MainActivity.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Surface import androidx.tv.material3.Text +import com.example.jetcaster.tv.ui.JetcasterApp import com.example.jetcaster.tv.ui.theme.JetcasterTheme class MainActivity : ComponentActivity() { @@ -39,7 +40,7 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), shape = RectangleShape ) { - Greeting("Android and World") + JetcasterApp() } } } 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 new file mode 100644 index 0000000000..415fffd04d --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt @@ -0,0 +1,177 @@ +/* + * 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 + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.VideoLibrary +import androidx.compose.runtime.Composable +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.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +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.library.LibraryScreen +import com.example.jetcaster.tv.ui.profile.ProfileScreen +import com.example.jetcaster.tv.ui.search.SearchScreen +import com.example.jetcaster.tv.ui.settings.SettingsScreen + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppState()) { + val (menu, discover) = FocusRequester.createRefs() + + NavigationDrawer( + drawerContent = { + Column( + modifier = Modifier + .padding( + JetcasterAppDefaults.overScanMargin + .copy( + start = 0.dp, + end = 0.dp + ) + .intoPaddingValues() + ) + .focusRequester(menu) + .focusRestorer { discover } + ) { + + NavigationDrawerItem( + selected = false, + onClick = jetcasterAppState::navigateToProfile, + leadingContent = { Icon(Icons.Default.Person, contentDescription = null) }, + ) { + Column { + Text(text = "Name") + Text(text = "Switch Account", style = MaterialTheme.typography.labelSmall) + } + } + Spacer(modifier = Modifier.weight(1f)) + NavigationDrawerItem( + selected = false, + onClick = jetcasterAppState::navigateToSearch, + leadingContent = { Icon(Icons.Default.Search, contentDescription = null) } + ) { + Text(text = "Search") + } + NavigationDrawerItem( + selected = false, + onClick = jetcasterAppState::navigateToDiscover, + leadingContent = { Icon(Icons.Default.Home, contentDescription = null) }, + modifier = Modifier.focusRequester(discover) + ) { + Text(text = "Discover") + } + NavigationDrawerItem( + selected = false, + onClick = jetcasterAppState::navigateToLibrary, + leadingContent = { Icon(Icons.Default.VideoLibrary, contentDescription = null) } + ) { + Text(text = "Library") + } + Spacer(modifier = Modifier.weight(1f)) + NavigationDrawerItem( + selected = false, + onClick = jetcasterAppState::navigateToSettings, + leadingContent = { Icon(Icons.Default.Settings, contentDescription = null) } + ) { + Text(text = "Settings") + } + } + } + ) { + Route(jetcasterAppState = jetcasterAppState) + } +} + +data object JetcasterAppDefaults { + val overScanMargin = OverScanMargin() +} + +data class OverScanMargin( + val top: Dp = 24.dp, + val bottom: Dp = 24.dp, + val start: Dp = 48.dp, + val end: Dp = 48.dp, +) { + fun intoPaddingValues(): PaddingValues { + return PaddingValues(start, top, end, bottom) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun Route(jetcasterAppState: JetcasterAppState) { + NavHost(navController = jetcasterAppState.navHostController, Screen.Discover.route) { + composable(Screen.Discover.route) { + DiscoverScreen( + modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + ) + } + + composable(Screen.Library.route) { + LibraryScreen( + modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + ) + } + + composable(Screen.Search.route) { + SearchScreen( + modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + ) + } + + composable(Screen.Show.route) { + Text(text = "Show") + } + + composable(Screen.Player.route) { + Text(text = "Player") + } + + composable(Screen.Profile.route) { + ProfileScreen( + modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + ) + } + + composable(Screen.Settings.route) { + SettingsScreen( + modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + ) + } + } +} 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 new file mode 100644 index 0000000000..5cb595729c --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt @@ -0,0 +1,110 @@ +/* + * 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 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController + +class JetcasterAppState( + val navHostController: NavHostController +) { + fun navigateToDiscover() { + navHostController.navigate(Screen.Discover.route) + } + + fun navigateToLibrary() { + navHostController.navigate(Screen.Library.route) + } + + fun navigateToProfile() { + navHostController.navigate(Screen.Profile.route) + } + + fun navigateToSearch() { + navHostController.navigate(Screen.Search.route) + } + + fun navigateToSettings() { + navHostController.navigate(Screen.Settings.route) + } + + fun showPodcastDetails(podcastUri: String) { + val screen = Screen.Show(podcastUri) + navHostController.navigate(screen.route) + } + + fun playEpisode(episodeUri: String) { + val screen = Screen.Player(episodeUri) + navHostController.navigate(screen.route) + } + + fun navigateBack() { + navHostController.popBackStack() + } +} + +@Composable +fun rememberJetcasterAppState( + navHostController: NavHostController = rememberNavController() +) = + remember(navHostController) { + JetcasterAppState(navHostController) + } + +sealed interface Screen { + val route: String + + data object Discover : Screen { + override val route = "/" + } + + data object Library : Screen { + override val route = "/library" + } + + data object Search : Screen { + override val route = "/search" + } + + data object Profile : Screen { + override val route = "/profile" + } + + data object Settings : Screen { + override val route: String = "/settings" + } + + data class Show(private val podcastUri: String) : Screen { + override val route = "$root/$podcastUri" + + companion object : Screen { + private const val root = "/show" + override val route = "$root/{showUri}" + } + } + + data class Player(private val episodeUri: String) : Screen { + override val route = "$root/$episodeUri" + + companion object : Screen { + private const val root = "/player" + override val route = "$root/{episodeUri}" + } + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/NotAvailableFeature.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/NotAvailableFeature.kt new file mode 100644 index 0000000000..5ceb504939 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/NotAvailableFeature.kt @@ -0,0 +1,33 @@ +/* + * 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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun NotAvailableFeature( + modifier: Modifier = Modifier, + message: String = stringResource(id = R.string.message_not_available_feature) +) { + Text(message, modifier = 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 new file mode 100644 index 0000000000..488ea6f310 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt @@ -0,0 +1,26 @@ +/* + * 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.discover + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.jetcaster.tv.ui.component.NotAvailableFeature + +@Composable +fun DiscoverScreen(modifier: Modifier = Modifier) { + NotAvailableFeature(modifier = modifier) +} 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 new file mode 100644 index 0000000000..fbcc617af2 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt @@ -0,0 +1,28 @@ +/* + * 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.library + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.jetcaster.tv.ui.component.NotAvailableFeature + +@Composable +fun LibraryScreen( + modifier: Modifier = Modifier +) { + NotAvailableFeature(modifier = modifier) +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/profile/ProfileScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000000..b9cdd39734 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/profile/ProfileScreen.kt @@ -0,0 +1,26 @@ +/* + * 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.profile + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.jetcaster.tv.ui.component.NotAvailableFeature + +@Composable +fun ProfileScreen(modifier: Modifier = Modifier) { + NotAvailableFeature(modifier = modifier) +} 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 new file mode 100644 index 0000000000..5f94ed1e32 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/search/SearchScreen.kt @@ -0,0 +1,26 @@ +/* + * 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.search + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.jetcaster.tv.ui.component.NotAvailableFeature + +@Composable +fun SearchScreen(modifier: Modifier = Modifier) { + NotAvailableFeature(modifier = modifier) +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/settings/SettingsScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000000..53bf32f50c --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/settings/SettingsScreen.kt @@ -0,0 +1,28 @@ +/* + * 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.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.jetcaster.tv.ui.component.NotAvailableFeature + +@Composable +fun SettingsScreen( + modifier: Modifier = Modifier +) { + NotAvailableFeature(modifier = modifier) +} diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml index 0c76d4a991..5979dbdc01 100644 --- a/Jetcaster/tv-app/src/main/res/values/strings.xml +++ b/Jetcaster/tv-app/src/main/res/values/strings.xml @@ -16,4 +16,5 @@ JetCaster + This feature is not available yet. \ No newline at end of file From b236b5d454fe732f6c4460d36355a82690cc1a8c Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Mon, 25 Mar 2024 17:02:55 +0900 Subject: [PATCH 2/7] Implementation of the discover screen --- Jetcaster/gradle/libs.versions.toml | 8 +- Jetcaster/tv-app/build.gradle.kts | 1 + Jetcaster/tv-app/src/main/AndroidManifest.xml | 5 +- .../example/jetcaster/tv/JetCasterTvApp.kt | 28 ++ .../jetcaster/tv/model/CategoryList.kt | 23 ++ .../example/jetcaster/tv/model/EpisodeList.kt | 21 ++ .../example/jetcaster/tv/model/PodcastList.kt | 25 ++ .../example/jetcaster/tv/ui/JetcasterApp.kt | 25 +- .../jetcaster/tv/ui/component/Loading.kt | 44 +++ .../tv/ui/discover/DiscoverScreen.kt | 330 +++++++++++++++++- .../tv/ui/discover/DiscoverScreenViewModel.kt | 120 +++++++ .../tv-app/src/main/res/values/strings.xml | 28 ++ 12 files changed, 647 insertions(+), 11 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/CategoryList.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/PodcastList.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Loading.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt diff --git a/Jetcaster/gradle/libs.versions.toml b/Jetcaster/gradle/libs.versions.toml index 99e9fca266..ebe7e6a71a 100644 --- a/Jetcaster/gradle/libs.versions.toml +++ b/Jetcaster/gradle/libs.versions.toml @@ -4,14 +4,14 @@ ##### [versions] accompanist = "0.34.0" -androidGradlePlugin = "8.2.0" +androidGradlePlugin = "8.3.1" androidx-activity-compose = "1.8.2" androidx-appcompat = "1.6.1" androidx-benchmark = "1.2.3" androidx-benchmark-junit4 = "1.2.3" -androidx-compose-bom = "2024.02.02" +androidx-compose-bom = "2024.03.00" androidx-constraintlayout = "1.0.1" -androidx-corektx = "1.13.0-alpha05" +androidx-corektx = "1.13.0-beta01" androidx-glance = "1.0.0" androidx-lifecycle-runtime = "2.7.0" androidx-lifecycle-compose = "2.7.0" @@ -34,7 +34,7 @@ compose-compiler = "1.5.4" coroutines = "1.8.0" google-maps = "18.2.0" gradle-versions = "0.51.0" -hilt = "2.49" +hilt = "2.51" hiltExt = "1.2.0" # @pin When updating to AGP 7.4.0-alpha10 and up we can update this https://developer.android.com/studio/write/java8-support#library-desugaring-versions jdkDesugar = "2.0.4" diff --git a/Jetcaster/tv-app/build.gradle.kts b/Jetcaster/tv-app/build.gradle.kts index 279be42441..d748aaa616 100644 --- a/Jetcaster/tv-app/build.gradle.kts +++ b/Jetcaster/tv-app/build.gradle.kts @@ -77,6 +77,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.activity.compose) implementation(libs.androidx.navigation.compose) + implementation(libs.coil.kt.compose) implementation(project(":core")) diff --git a/Jetcaster/tv-app/src/main/AndroidManifest.xml b/Jetcaster/tv-app/src/main/AndroidManifest.xml index dac79d860d..3ab2d935a4 100644 --- a/Jetcaster/tv-app/src/main/AndroidManifest.xml +++ b/Jetcaster/tv-app/src/main/AndroidManifest.xml @@ -24,13 +24,16 @@ android:name="android.software.leanback" android:required="false" /> + + + android:theme="@style/Theme.Jetcaster" + android:name=".JetCasterTvApp"> diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt new file mode 100644 index 0000000000..413098fc13 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/JetCasterTvApp.kt @@ -0,0 +1,28 @@ +/* + * 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 + +import android.app.Application +import com.example.jetcaster.core.data.di.Graph + +class JetCasterTvApp : Application() { + + override fun onCreate() { + super.onCreate() + Graph.provide(this) + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/CategoryList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/CategoryList.kt new file mode 100644 index 0000000000..34643d8e2a --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/CategoryList.kt @@ -0,0 +1,23 @@ +/* + * 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.model + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.data.database.model.Category + +@Immutable +data class CategoryList(val member: List) : List by member diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt new file mode 100644 index 0000000000..c40bc87261 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt @@ -0,0 +1,21 @@ +/* + * 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.model + +import com.example.jetcaster.core.data.database.model.EpisodeToPodcast + +data class EpisodeList(val member: List) : List by member diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/PodcastList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/PodcastList.kt new file mode 100644 index 0000000000..6c623e7fce --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/PodcastList.kt @@ -0,0 +1,25 @@ +/* + * 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.model + +import androidx.compose.runtime.Immutable +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo + +@Immutable +data class PodcastList( + val member: List +) : List by member 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 415fffd04d..ede2c51318 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 @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.VideoLibrary 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.FocusRequester @@ -51,7 +52,7 @@ import com.example.jetcaster.tv.ui.settings.SettingsScreen @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppState()) { - val (menu, discover) = FocusRequester.createRefs() + val discover = remember { FocusRequester() } NavigationDrawer( drawerContent = { @@ -65,7 +66,6 @@ fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppStat ) .intoPaddingValues() ) - .focusRequester(menu) .focusRestorer { discover } ) { @@ -117,8 +117,11 @@ fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppStat } } -data object JetcasterAppDefaults { +internal data object JetcasterAppDefaults { val overScanMargin = OverScanMargin() + val gapSettings = GapSettings() + val cardWidth = CardWidth() + val padding = PaddingSettings() } data class OverScanMargin( @@ -132,6 +135,22 @@ data class OverScanMargin( } } +data class CardWidth( + val large: Dp = 268.dp, + val medium: Dp = 196.dp, + val small: Dp = 124.dp +) + +data class PaddingSettings( + val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), + val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp) +) + +data class GapSettings( + val catalogItemGap: Dp = 20.dp, + val catalogSectionGap: Dp = 40.dp, +) + @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun Route(jetcasterAppState: JetcasterAppState) { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Loading.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Loading.kt new file mode 100644 index 0000000000..15b6c386c1 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Loading.kt @@ -0,0 +1,44 @@ +/* + * 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.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun Loading( + modifier: Modifier = Modifier, + message: String = stringResource(id = R.string.message_loading), + contentAlignment: Alignment = Alignment.Center, + style: TextStyle = MaterialTheme.typography.displayMedium +) { + Box( + modifier = modifier, + contentAlignment = contentAlignment + ) { + Text(text = message, style = style) + } +} 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 488ea6f310..41154160c9 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 @@ -16,11 +16,335 @@ package com.example.jetcaster.tv.ui.discover +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import com.example.jetcaster.tv.ui.component.NotAvailableFeature +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +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.lifecycle.viewmodel.compose.viewModel +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.TvLazyRow +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.Card +import androidx.tv.material3.CardScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.StandardCardLayout +import androidx.tv.material3.Tab +import androidx.tv.material3.TabRow +import androidx.tv.material3.Text +import androidx.tv.material3.WideCardLayout +import coil.compose.AsyncImage +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.R +import com.example.jetcaster.tv.model.CategoryList +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +import com.example.jetcaster.tv.ui.JetcasterAppDefaults +import com.example.jetcaster.tv.ui.component.Loading +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle @Composable -fun DiscoverScreen(modifier: Modifier = Modifier) { - NotAvailableFeature(modifier = modifier) +fun DiscoverScreen( + modifier: Modifier = Modifier, + discoverScreenViewModel: DiscoverScreenViewModel = viewModel() +) { + val uiState by discoverScreenViewModel.uiState.collectAsState() + + when (val s = uiState) { + DiscoverScreenUiState.Loading -> { + Loading( + modifier = Modifier + .fillMaxSize() + .then(modifier) + ) + } + + is DiscoverScreenUiState.Ready -> { + Catalog( + categoryList = s.categoryList, + podcastList = s.podcastList, + selectedCategory = s.selectedCategory, + latestEpisodeList = s.latestEpisodeList, + onPodcastSelected = {}, + onCategorySelected = discoverScreenViewModel::selectCategory, + modifier = Modifier + .fillMaxSize() + .then(modifier) + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun Catalog( + categoryList: CategoryList, + podcastList: PodcastList, + selectedCategory: Category, + latestEpisodeList: EpisodeList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + onCategorySelected: (Category) -> Unit, + modifier: Modifier = Modifier, +) { + val tabRow = remember(categoryList) { FocusRequester() } + + LaunchedEffect(Unit) { + tabRow.requestFocus() + } + + TvLazyColumn( + modifier = modifier, + contentPadding = JetcasterAppDefaults + .overScanMargin + .copy(start = 0.dp, end = 0.dp) + .intoPaddingValues(), + verticalArrangement = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) + ) { + item { + TabRow( + selectedTabIndex = categoryList.indexOf(selectedCategory), + modifier = Modifier.focusRequester(tabRow) + ) { + categoryList.forEach { + Tab(selected = it == selectedCategory, onFocus = { onCategorySelected(it) }) { + Text( + text = it.name, + modifier = Modifier.padding(JetcasterAppDefaults.padding.tab) + ) + } + } + } + } + item { + PodcastSection( + podcastList = podcastList, + onPodcastSelected = onPodcastSelected, + title = stringResource(R.string.label_podcast) + ) + } + item { + LatestEpisodeSection( + episodeList = latestEpisodeList, + onEpisodeSelected = {}, + title = stringResource(R.string.label_latest_episode) + ) + } + } +} + +@Composable +private fun PodcastSection( + podcastList: PodcastList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + modifier: Modifier = Modifier, + title: String? = null, +) { + Section( + title = title, + modifier = modifier + ) { + PodcastRow(podcastList = podcastList, onPodcastSelected = onPodcastSelected) + } +} + +@Composable +private fun LatestEpisodeSection( + episodeList: EpisodeList, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier, + title: String? = null +) { + Section( + modifier = modifier, + title = title + ) { + EpisodeRow(episodeList = episodeList, onEpisodeSelected = onEpisodeSelected) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun Section( + modifier: Modifier = Modifier, + title: String? = null, + style: TextStyle = MaterialTheme.typography.headlineMedium, + content: @Composable () -> Unit, +) { + Column(modifier) { + if (title != null) { + Text( + text = title, + style = style, + modifier = Modifier.padding(JetcasterAppDefaults.padding.sectionTitle) + ) + } + content() + } +} + +@Composable +private fun PodcastRow( + podcastList: PodcastList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), +) { + TvLazyRow( + contentPadding = contentPadding, + horizontalArrangement = horizontalArrangement, + modifier = modifier, + ) { + items(podcastList) { + PodcastCard( + podcast = it.podcast, + onClick = { onPodcastSelected(it) }, + modifier = Modifier.width(JetcasterAppDefaults.cardWidth.medium) + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun PodcastCard( + podcast: Podcast, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + StandardCardLayout( + imageCard = { + Card( + onClick = onClick, + interactionSource = it, + scale = CardScale.None, + ) { + AsyncImage(model = podcast.imageUrl, contentDescription = null) + } + }, + title = { + Text(text = podcast.title, modifier = Modifier.padding(top = 12.dp)) + }, + modifier = modifier, + ) +} + +@Composable +private fun EpisodeRow( + episodeList: EpisodeList, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), +) { + TvLazyRow( + contentPadding = contentPadding, + horizontalArrangement = horizontalArrangement, + modifier = modifier, + ) { + items(episodeList) { + EpisodeCard( + episode = it, + onClick = { onEpisodeSelected(it) }, + modifier = Modifier.width(JetcasterAppDefaults.cardWidth.small) + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeCard( + episode: EpisodeToPodcast, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + WideCardLayout( + imageCard = { + EpisodeThumbnail(episode = episode, onClick = onClick, modifier = modifier) + }, + title = { + EpisodeMetaData( + episode = episode, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .width(JetcasterAppDefaults.cardWidth.small * 2) + ) + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeThumbnail( + episode: EpisodeToPodcast, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + Card( + onClick = onClick, + interactionSource = interactionSource, + scale = CardScale.None, + modifier = modifier, + ) { + AsyncImage(model = episode.podcast.imageUrl, contentDescription = null) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeMetaData(episode: EpisodeToPodcast, modifier: Modifier = Modifier) { + val publishedDate = episode.episode.published + val duration = episode.episode.duration + Column(modifier = modifier) { + Text( + text = episode.episode.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text(text = episode.podcast.title, style = MaterialTheme.typography.bodySmall) + if (duration != null) { + Spacer( + modifier = Modifier.height(JetcasterAppDefaults.gapSettings.catalogItemGap * 0.8f) + ) + Text( + text = stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(publishedDate), + duration.toMinutes().toInt() + ), + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +private val MediumDateFormatter by lazy { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt new file mode 100644 index 0000000000..22e1902a07 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt @@ -0,0 +1,120 @@ +/* + * 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.discover + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.di.Graph +import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.tv.model.CategoryList +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class DiscoverScreenViewModel( + private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, + private val categoryStore: CategoryStore = Graph.categoryStore, +) : ViewModel() { + + private val _selectedCategory = MutableStateFlow(null) + private val selectedCategoryFlow = combine( + categoryStore.categoriesSortedByPodcastCount(), + _selectedCategory + ) { categoryList, category -> + Log.d("category list", "$categoryList") + category ?: categoryList.firstOrNull() + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val podcastInSelectedCategory = selectedCategoryFlow.flatMapLatest { + if (it != null) { + categoryStore.podcastsInCategorySortedByPodcastCount(it.id) + } else { + flowOf(emptyList()) + } + }.map { + PodcastList(it) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val latestEpisodeFlow = selectedCategoryFlow.flatMapLatest { + if (it != null) { + categoryStore.episodesFromPodcastsInCategory(it.id, 20) + } else { + flowOf(emptyList()) + } + }.map { + EpisodeList(it) + } + + val uiState = combine( + categoryStore.categoriesSortedByPodcastCount(), + selectedCategoryFlow, + podcastInSelectedCategory, + latestEpisodeFlow, + ) { categoryList, category, podcastList, latestEpisodes -> + if (category != null) { + DiscoverScreenUiState.Ready( + CategoryList(categoryList), + category, + podcastList, + latestEpisodes + ) + } else { + DiscoverScreenUiState.Loading + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + DiscoverScreenUiState.Loading + ) + + init { + refresh() + } + + fun selectCategory(category: Category) { + _selectedCategory.value = category + } + + private fun refresh() { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } +} + +sealed interface DiscoverScreenUiState { + data object Loading : DiscoverScreenUiState + data class Ready( + val categoryList: CategoryList, + val selectedCategory: Category, + val podcastList: PodcastList, + val latestEpisodeList: EpisodeList, + ) : DiscoverScreenUiState +} diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml index 5979dbdc01..2c853aa4a6 100644 --- a/Jetcaster/tv-app/src/main/res/values/strings.xml +++ b/Jetcaster/tv-app/src/main/res/values/strings.xml @@ -17,4 +17,32 @@ JetCaster This feature is not available yet. + Loading + Podcast + Latest Episodes + Subscribe + Info + Play + Pause + Skip 10 seconds + Rewind 10 seconds + Play the next episode + Play the previous episode + Listen + Podcasts + Episodes + Latest Episodes + + Updated a while ago + + Updated %d week ago + Updated %d weeks ago + + + Updated yesterday + Updated %d days ago + + Updated today + + %1$s • %2$d mins \ No newline at end of file From d97138544a0c18bec4a2ea047f7179223bc2432f Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 26 Mar 2024 11:13:04 +0900 Subject: [PATCH 3/7] Add the library screen to TV app --- .../example/jetcaster/tv/ui/JetcasterApp.kt | 16 +- .../jetcaster/tv/ui/component/Catalog.kt | 284 ++++++++++++++++++ .../tv/ui/discover/DiscoverScreen.kt | 280 ++--------------- .../tv/ui/discover/DiscoverScreenViewModel.kt | 11 + .../jetcaster/tv/ui/library/LibraryScreen.kt | 59 +++- .../tv/ui/library/LibraryScreenViewModel.kt | 99 ++++++ .../tv-app/src/main/res/values/strings.xml | 3 + 7 files changed, 489 insertions(+), 263 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt 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 ede2c51318..1c732e9861 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 @@ -19,6 +19,7 @@ package com.example.jetcaster.tv.ui 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.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home @@ -111,7 +112,7 @@ fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppStat Text(text = "Settings") } } - } + }, ) { Route(jetcasterAppState = jetcasterAppState) } @@ -157,19 +158,26 @@ private fun Route(jetcasterAppState: JetcasterAppState) { NavHost(navController = jetcasterAppState.navHostController, Screen.Discover.route) { composable(Screen.Discover.route) { DiscoverScreen( - modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .fillMaxSize() ) } composable(Screen.Library.route) { LibraryScreen( - modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + navigateToDiscover = jetcasterAppState::navigateToDiscover, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .fillMaxSize() ) } composable(Screen.Search.route) { SearchScreen( - modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .fillMaxSize() ) } 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 new file mode 100644 index 0000000000..f1bda1b9ed --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt @@ -0,0 +1,284 @@ +/* + * 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.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +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.Modifier +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.TvLazyRow +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.Card +import androidx.tv.material3.CardScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.StandardCardLayout +import androidx.tv.material3.Text +import androidx.tv.material3.WideCardLayout +import coil.compose.AsyncImage +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.R +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +import com.example.jetcaster.tv.ui.JetcasterAppDefaults +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +@Composable +internal fun Catalog( + podcastList: PodcastList, + latestEpisodeList: EpisodeList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + modifier: Modifier = Modifier, + header: (@Composable () -> Unit)? = null, +) { + TvLazyColumn( + modifier = modifier, + contentPadding = JetcasterAppDefaults.overScanMargin + .copy(start = 0.dp, end = 0.dp) + .intoPaddingValues(), + verticalArrangement = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) + ) { + if (header != null) { + item { header() } + } + item { + PodcastSection( + podcastList = podcastList, + onPodcastSelected = onPodcastSelected, + title = stringResource(R.string.label_podcast) + ) + } + item { + LatestEpisodeSection( + episodeList = latestEpisodeList, + onEpisodeSelected = {}, + title = stringResource(R.string.label_latest_episode) + ) + } + } +} + +@Composable +private fun PodcastSection( + podcastList: PodcastList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + modifier: Modifier = Modifier, + title: String? = null, +) { + Section( + title = title, + modifier = modifier + ) { + PodcastRow(podcastList = podcastList, onPodcastSelected = onPodcastSelected) + } +} + +@Composable +private fun LatestEpisodeSection( + episodeList: EpisodeList, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier, + title: String? = null +) { + Section( + modifier = modifier, + title = title + ) { + EpisodeRow(episodeList = episodeList, onEpisodeSelected = onEpisodeSelected) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun Section( + modifier: Modifier = Modifier, + title: String? = null, + style: TextStyle = MaterialTheme.typography.headlineMedium, + content: @Composable () -> Unit, +) { + Column(modifier) { + if (title != null) { + Text( + text = title, + style = style, + modifier = Modifier.padding(JetcasterAppDefaults.padding.sectionTitle) + ) + } + content() + } +} + +@Composable +private fun PodcastRow( + podcastList: PodcastList, + onPodcastSelected: (PodcastWithExtraInfo) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), +) { + TvLazyRow( + contentPadding = contentPadding, + horizontalArrangement = horizontalArrangement, + modifier = modifier, + ) { + items(podcastList) { + PodcastCard( + podcast = it.podcast, + onClick = { onPodcastSelected(it) }, + modifier = Modifier.width(JetcasterAppDefaults.cardWidth.medium) + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun PodcastCard( + podcast: Podcast, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + StandardCardLayout( + imageCard = { + Card( + onClick = onClick, + interactionSource = it, + scale = CardScale.None, + ) { + AsyncImage(model = podcast.imageUrl, contentDescription = null) + } + }, + title = { + Text(text = podcast.title, modifier = Modifier.padding(top = 12.dp)) + }, + modifier = modifier, + ) +} + +@Composable +private fun EpisodeRow( + episodeList: EpisodeList, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + horizontalArrangement: Arrangement.Horizontal = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), +) { + TvLazyRow( + contentPadding = contentPadding, + horizontalArrangement = horizontalArrangement, + modifier = modifier, + ) { + items(episodeList) { + EpisodeCard( + episode = it, + onClick = { onEpisodeSelected(it) }, + modifier = Modifier.width(JetcasterAppDefaults.cardWidth.small) + ) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeCard( + episode: EpisodeToPodcast, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + WideCardLayout( + imageCard = { + EpisodeThumbnail(episode = episode, onClick = onClick, modifier = modifier) + }, + title = { + EpisodeMetaData( + episode = episode, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp) + .width(JetcasterAppDefaults.cardWidth.small * 2) + ) + }, + ) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeThumbnail( + episode: EpisodeToPodcast, + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } +) { + Card( + onClick = onClick, + interactionSource = interactionSource, + scale = CardScale.None, + modifier = modifier, + ) { + AsyncImage(model = episode.podcast.imageUrl, contentDescription = null) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeMetaData(episode: EpisodeToPodcast, modifier: Modifier = Modifier) { + val publishedDate = episode.episode.published + val duration = episode.episode.duration + Column(modifier = modifier) { + Text( + text = episode.episode.title, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text(text = episode.podcast.title, style = MaterialTheme.typography.bodySmall) + if (duration != null) { + Spacer( + modifier = Modifier.height(JetcasterAppDefaults.gapSettings.catalogItemGap * 0.8f) + ) + Text( + text = stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(publishedDate), + duration.toMinutes().toInt() + ), + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +private val MediumDateFormatter by lazy { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) +} 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 41154160c9..57b2cf481b 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 @@ -16,53 +16,30 @@ package com.example.jetcaster.tv.ui.discover -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement -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.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.focusRequester -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.lifecycle.viewmodel.compose.viewModel -import androidx.tv.foundation.lazy.list.TvLazyColumn -import androidx.tv.foundation.lazy.list.TvLazyRow -import androidx.tv.foundation.lazy.list.items -import androidx.tv.material3.Card -import androidx.tv.material3.CardScale import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.StandardCardLayout import androidx.tv.material3.Tab import androidx.tv.material3.TabRow import androidx.tv.material3.Text -import androidx.tv.material3.WideCardLayout -import coil.compose.AsyncImage 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.R import com.example.jetcaster.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList import com.example.jetcaster.tv.ui.JetcasterAppDefaults +import com.example.jetcaster.tv.ui.component.Catalog import com.example.jetcaster.tv.ui.component.Loading -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle @Composable fun DiscoverScreen( @@ -81,12 +58,12 @@ fun DiscoverScreen( } is DiscoverScreenUiState.Ready -> { - Catalog( + CatalogWithCategorySelection( categoryList = s.categoryList, podcastList = s.podcastList, selectedCategory = s.selectedCategory, latestEpisodeList = s.latestEpisodeList, - onPodcastSelected = {}, + onPodcastSelected = discoverScreenViewModel::subscribe, onCategorySelected = discoverScreenViewModel::selectCategory, modifier = Modifier .fillMaxSize() @@ -96,9 +73,9 @@ fun DiscoverScreen( } } -@OptIn(ExperimentalTvMaterial3Api::class) +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable -private fun Catalog( +private fun CatalogWithCategorySelection( categoryList: CategoryList, podcastList: PodcastList, selectedCategory: Category, @@ -113,238 +90,29 @@ private fun Catalog( tabRow.requestFocus() } - TvLazyColumn( + Catalog( + podcastList = podcastList, + latestEpisodeList = latestEpisodeList, + onPodcastSelected = onPodcastSelected, modifier = modifier, - contentPadding = JetcasterAppDefaults - .overScanMargin - .copy(start = 0.dp, end = 0.dp) - .intoPaddingValues(), - verticalArrangement = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) ) { - item { - TabRow( - selectedTabIndex = categoryList.indexOf(selectedCategory), - modifier = Modifier.focusRequester(tabRow) - ) { - categoryList.forEach { - Tab(selected = it == selectedCategory, onFocus = { onCategorySelected(it) }) { - Text( - text = it.name, - modifier = Modifier.padding(JetcasterAppDefaults.padding.tab) - ) + TabRow( + selectedTabIndex = categoryList.indexOf(selectedCategory), + modifier = Modifier.focusRequester(tabRow) + ) { + categoryList.forEach { + Tab( + selected = it == selectedCategory, + onFocus = { + onCategorySelected(it) } + ) { + Text( + text = it.name, + modifier = Modifier.padding(JetcasterAppDefaults.padding.tab) + ) } } } - item { - PodcastSection( - podcastList = podcastList, - onPodcastSelected = onPodcastSelected, - title = stringResource(R.string.label_podcast) - ) - } - item { - LatestEpisodeSection( - episodeList = latestEpisodeList, - onEpisodeSelected = {}, - title = stringResource(R.string.label_latest_episode) - ) - } - } -} - -@Composable -private fun PodcastSection( - podcastList: PodcastList, - onPodcastSelected: (PodcastWithExtraInfo) -> Unit, - modifier: Modifier = Modifier, - title: String? = null, -) { - Section( - title = title, - modifier = modifier - ) { - PodcastRow(podcastList = podcastList, onPodcastSelected = onPodcastSelected) - } -} - -@Composable -private fun LatestEpisodeSection( - episodeList: EpisodeList, - onEpisodeSelected: (EpisodeToPodcast) -> Unit, - modifier: Modifier = Modifier, - title: String? = null -) { - Section( - modifier = modifier, - title = title - ) { - EpisodeRow(episodeList = episodeList, onEpisodeSelected = onEpisodeSelected) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun Section( - modifier: Modifier = Modifier, - title: String? = null, - style: TextStyle = MaterialTheme.typography.headlineMedium, - content: @Composable () -> Unit, -) { - Column(modifier) { - if (title != null) { - Text( - text = title, - style = style, - modifier = Modifier.padding(JetcasterAppDefaults.padding.sectionTitle) - ) - } - content() - } -} - -@Composable -private fun PodcastRow( - podcastList: PodcastList, - onPodcastSelected: (PodcastWithExtraInfo) -> Unit, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(), - horizontalArrangement: Arrangement.Horizontal = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), -) { - TvLazyRow( - contentPadding = contentPadding, - horizontalArrangement = horizontalArrangement, - modifier = modifier, - ) { - items(podcastList) { - PodcastCard( - podcast = it.podcast, - onClick = { onPodcastSelected(it) }, - modifier = Modifier.width(JetcasterAppDefaults.cardWidth.medium) - ) - } } } - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun PodcastCard( - podcast: Podcast, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - StandardCardLayout( - imageCard = { - Card( - onClick = onClick, - interactionSource = it, - scale = CardScale.None, - ) { - AsyncImage(model = podcast.imageUrl, contentDescription = null) - } - }, - title = { - Text(text = podcast.title, modifier = Modifier.padding(top = 12.dp)) - }, - modifier = modifier, - ) -} - -@Composable -private fun EpisodeRow( - episodeList: EpisodeList, - onEpisodeSelected: (EpisodeToPodcast) -> Unit, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(), - horizontalArrangement: Arrangement.Horizontal = - Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), -) { - TvLazyRow( - contentPadding = contentPadding, - horizontalArrangement = horizontalArrangement, - modifier = modifier, - ) { - items(episodeList) { - EpisodeCard( - episode = it, - onClick = { onEpisodeSelected(it) }, - modifier = Modifier.width(JetcasterAppDefaults.cardWidth.small) - ) - } - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun EpisodeCard( - episode: EpisodeToPodcast, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - WideCardLayout( - imageCard = { - EpisodeThumbnail(episode = episode, onClick = onClick, modifier = modifier) - }, - title = { - EpisodeMetaData( - episode = episode, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 12.dp) - .width(JetcasterAppDefaults.cardWidth.small * 2) - ) - }, - ) -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun EpisodeThumbnail( - episode: EpisodeToPodcast, - onClick: () -> Unit, - modifier: Modifier = Modifier, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } -) { - Card( - onClick = onClick, - interactionSource = interactionSource, - scale = CardScale.None, - modifier = modifier, - ) { - AsyncImage(model = episode.podcast.imageUrl, contentDescription = null) - } -} - -@OptIn(ExperimentalTvMaterial3Api::class) -@Composable -private fun EpisodeMetaData(episode: EpisodeToPodcast, modifier: Modifier = Modifier) { - val publishedDate = episode.episode.published - val duration = episode.episode.duration - Column(modifier = modifier) { - Text( - text = episode.episode.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text(text = episode.podcast.title, style = MaterialTheme.typography.bodySmall) - if (duration != null) { - Spacer( - modifier = Modifier.height(JetcasterAppDefaults.gapSettings.catalogItemGap * 0.8f) - ) - Text( - text = stringResource( - R.string.episode_date_duration, - MediumDateFormatter.format(publishedDate), - duration.toMinutes().toInt() - ), - style = MaterialTheme.typography.bodySmall - ) - } - } -} - -private val MediumDateFormatter by lazy { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) -} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt index 22e1902a07..911b92eb03 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt @@ -20,8 +20,10 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.Category +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.CategoryStore +import com.example.jetcaster.core.data.repository.PodcastStore import com.example.jetcaster.core.data.repository.PodcastsRepository import com.example.jetcaster.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList @@ -39,6 +41,7 @@ import kotlinx.coroutines.launch class DiscoverScreenViewModel( private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, private val categoryStore: CategoryStore = Graph.categoryStore, + private val podcastStore: PodcastStore = Graph.podcastStore, ) : ViewModel() { private val _selectedCategory = MutableStateFlow(null) @@ -102,6 +105,14 @@ class DiscoverScreenViewModel( _selectedCategory.value = category } + fun subscribe(podcastWithExtraInfo: PodcastWithExtraInfo) { + if (!podcastWithExtraInfo.isFollowed) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcastWithExtraInfo.podcast.uri) + } + } + } + private fun refresh() { viewModelScope.launch { podcastsRepository.updatePodcasts(false) 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 fbcc617af2..bd2d70ac09 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 @@ -16,13 +16,66 @@ package com.example.jetcaster.tv.ui.library +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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 com.example.jetcaster.tv.ui.component.NotAvailableFeature +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.tv.R +import com.example.jetcaster.tv.ui.JetcasterAppDefaults +import com.example.jetcaster.tv.ui.component.Catalog +import com.example.jetcaster.tv.ui.component.Loading @Composable fun LibraryScreen( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + navigateToDiscover: () -> Unit, + libraryScreenViewModel: LibraryScreenViewModel = viewModel() ) { - NotAvailableFeature(modifier = modifier) + val uiState by libraryScreenViewModel.uiState.collectAsState() + when (val s = uiState) { + LibraryScreenUiState.Loading -> Loading(modifier = modifier) + LibraryScreenUiState.NoSubscribedPodcast -> { + NavigateToDiscover(onNavigationRequested = navigateToDiscover, modifier = modifier) + } + + is LibraryScreenUiState.Ready -> Catalog( + podcastList = s.subscribedPodcastList, + latestEpisodeList = s.latestEpisodeList, + onPodcastSelected = libraryScreenViewModel::unsubscribe, + modifier = modifier + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun NavigateToDiscover( + onNavigationRequested: () -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column { + Text( + text = stringResource(id = R.string.display_no_subscribed_podcast), + style = MaterialTheme.typography.displayMedium + ) + Text(text = stringResource(id = R.string.message_no_subscribed_podcast)) + Button( + onClick = onNavigationRequested, + modifier = Modifier.padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + ) { + Text(text = stringResource(id = R.string.label_navigate_to_discover)) + } + } + } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt new file mode 100644 index 0000000000..bc0fef41a5 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -0,0 +1,99 @@ +/* + * 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.library + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo +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.core.data.repository.PodcastsRepository +import com.example.jetcaster.tv.model.EpisodeList +import com.example.jetcaster.tv.model.PodcastList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class LibraryScreenViewModel( + private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, + private val episodeStore: EpisodeStore = Graph.episodeStore, + private val podcastStore: PodcastStore = Graph.podcastStore, +) : ViewModel() { + + private val followingPodcastListFlow = podcastStore.followedPodcastsSortedByLastEpisode().map { + PodcastList(it) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private val latestEpisodeListFlow = podcastStore + .followedPodcastsSortedByLastEpisode() + .flatMapLatest { podcastList -> + if (podcastList.isNotEmpty()) { + combine(podcastList.map { episodeStore.episodesInPodcast(it.podcast.uri, 1) }) { + it.map { episodes -> + episodes.first() + } + } + } else { + flowOf(emptyList()) + } + }.map { + EpisodeList(it) + } + + val uiState = + combine(followingPodcastListFlow, latestEpisodeListFlow) { podcastList, episodeList -> + if (podcastList.isEmpty()) { + LibraryScreenUiState.NoSubscribedPodcast + } else { + LibraryScreenUiState.Ready(podcastList, episodeList) + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + LibraryScreenUiState.Loading + ) + + fun unsubscribe(podcast: PodcastWithExtraInfo) { + if (podcast.isFollowed) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.podcast.uri) + } + } + } + + init { + viewModelScope.launch { + podcastsRepository.updatePodcasts(false) + } + } +} + +sealed interface LibraryScreenUiState { + data object Loading : LibraryScreenUiState + data object NoSubscribedPodcast : LibraryScreenUiState + data class Ready( + val subscribedPodcastList: PodcastList, + val latestEpisodeList: EpisodeList, + ) : LibraryScreenUiState +} diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml index 2c853aa4a6..ea00a4daf9 100644 --- a/Jetcaster/tv-app/src/main/res/values/strings.xml +++ b/Jetcaster/tv-app/src/main/res/values/strings.xml @@ -18,6 +18,8 @@ JetCaster This feature is not available yet. Loading + Let\'s discover the podcasts! + You subscribe no podcast yet. Let\'s discover the podcasts and subscribe them! Podcast Latest Episodes Subscribe @@ -32,6 +34,7 @@ Podcasts Episodes Latest Episodes + Discover the podcasts Updated a while ago From 861774f4e0cb3189eeda3b3d2046ccefd2ac2db7 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 26 Mar 2024 15:51:12 +0900 Subject: [PATCH 4/7] Add podcast details screen to the TV app --- .../example/jetcaster/tv/ui/JetcasterApp.kt | 23 +- .../jetcaster/tv/ui/JetcasterAppState.kt | 28 +- .../tv/ui/component/ButtonWithIcon.kt | 48 ++++ .../jetcaster/tv/ui/component/Catalog.kt | 15 +- .../tv/ui/component/EpisodeDateAndDuration.kt | 53 ++++ .../jetcaster/tv/ui/component/ErrorState.kt | 63 +++++ .../tv/ui/discover/DiscoverScreen.kt | 4 +- .../jetcaster/tv/ui/library/LibraryScreen.kt | 16 +- .../tv/ui/library/LibraryScreenViewModel.kt | 9 - .../jetcaster/tv/ui/podcast/PodcastScreen.kt | 266 ++++++++++++++++++ .../tv/ui/podcast/PodcastScreenViewModel.kt | 121 ++++++++ .../tv-app/src/main/res/values/strings.xml | 3 + 12 files changed, 608 insertions(+), 41 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt 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 1c732e9861..ff74c1bf7e 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 @@ -36,6 +36,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.tv.material3.ExperimentalTvMaterial3Api @@ -46,6 +47,8 @@ import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.Text import com.example.jetcaster.tv.ui.discover.DiscoverScreen import com.example.jetcaster.tv.ui.library.LibraryScreen +import com.example.jetcaster.tv.ui.podcast.PodcastScreen +import com.example.jetcaster.tv.ui.podcast.PodcastScreenViewModel import com.example.jetcaster.tv.ui.profile.ProfileScreen import com.example.jetcaster.tv.ui.search.SearchScreen import com.example.jetcaster.tv.ui.settings.SettingsScreen @@ -158,6 +161,9 @@ private fun Route(jetcasterAppState: JetcasterAppState) { NavHost(navController = jetcasterAppState.navHostController, Screen.Discover.route) { composable(Screen.Discover.route) { DiscoverScreen( + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.uri) + }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) .fillMaxSize() @@ -167,6 +173,9 @@ private fun Route(jetcasterAppState: JetcasterAppState) { composable(Screen.Library.route) { LibraryScreen( navigateToDiscover = jetcasterAppState::navigateToDiscover, + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.podcast.uri) + }, modifier = Modifier .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) .fillMaxSize() @@ -181,8 +190,18 @@ private fun Route(jetcasterAppState: JetcasterAppState) { ) } - composable(Screen.Show.route) { - Text(text = "Show") + composable(Screen.Podcast.route) { + val podcastScreenViewModel: PodcastScreenViewModel = viewModel( + factory = PodcastScreenViewModel.factory + ) + PodcastScreen( + podcastScreenViewModel = podcastScreenViewModel, + backToHomeScreen = jetcasterAppState::navigateToDiscover, + playEpisode = {}, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .fillMaxSize(), + ) } composable(Screen.Player.route) { 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 5cb595729c..600f2c6d16 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 @@ -16,6 +16,7 @@ package com.example.jetcaster.tv.ui +import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.navigation.NavHostController @@ -45,7 +46,8 @@ class JetcasterAppState( } fun showPodcastDetails(podcastUri: String) { - val screen = Screen.Show(podcastUri) + val encodedUrL = Uri.encode(podcastUri) + val screen = Screen.Podcast(encodedUrL) navHostController.navigate(screen.route) } @@ -71,40 +73,40 @@ sealed interface Screen { val route: String data object Discover : Screen { - override val route = "/" + override val route = "/discover" } data object Library : Screen { - override val route = "/library" + override val route = "library" } data object Search : Screen { - override val route = "/search" + override val route = "search" } data object Profile : Screen { - override val route = "/profile" + override val route = "profile" } data object Settings : Screen { - override val route: String = "/settings" + override val route: String = "settings" } - data class Show(private val podcastUri: String) : Screen { - override val route = "$root/$podcastUri" + data class Podcast(private val podcastUri: String) : Screen { + override val route = "$ROOT/$podcastUri" companion object : Screen { - private const val root = "/show" - override val route = "$root/{showUri}" + private const val ROOT = "podcast" + override val route = "$ROOT/{podcastUri}" } } data class Player(private val episodeUri: String) : Screen { - override val route = "$root/$episodeUri" + override val route = "$ROOT/$episodeUri" companion object : Screen { - private const val root = "/player" - override val route = "$root/{episodeUri}" + private const val ROOT = "player" + override val route = "$ROOT/{episodeUri}" } } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt new file mode 100644 index 0000000000..f39f87e12f --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt @@ -0,0 +1,48 @@ +/* + * 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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.ButtonScale +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.Icon +import androidx.tv.material3.Text + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun ButtonWithIcon( + label: String, + icon: ImageVector, + onClick: () -> Unit, + scale: ButtonScale = ButtonDefaults.scale(), + modifier: Modifier = Modifier, +) { + Button(onClick = onClick, modifier = modifier, scale = scale) { + Icon( + icon, + contentDescription = null, + Modifier.padding(top = 10.dp, bottom = 10.dp, start = 12.dp, end = 6.dp) + ) + Text(text = label, modifier = Modifier.padding(top = 10.dp, bottom = 10.dp, end = 16.dp)) + } +} 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 f1bda1b9ed..9a31b2a456 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 @@ -49,8 +49,6 @@ 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.JetcasterAppDefaults -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle @Composable internal fun Catalog( @@ -267,18 +265,7 @@ private fun EpisodeMetaData(episode: EpisodeToPodcast, modifier: Modifier = Modi Spacer( modifier = Modifier.height(JetcasterAppDefaults.gapSettings.catalogItemGap * 0.8f) ) - Text( - text = stringResource( - R.string.episode_date_duration, - MediumDateFormatter.format(publishedDate), - duration.toMinutes().toInt() - ), - style = MaterialTheme.typography.bodySmall - ) + EpisodeDataAndDuration(offsetDateTime = publishedDate, duration = duration) } } } - -private val MediumDateFormatter by lazy { - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) -} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt new file mode 100644 index 0000000000..d886364521 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/EpisodeDateAndDuration.kt @@ -0,0 +1,53 @@ +/* + * 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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R +import java.time.Duration +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +private val MediumDateFormatter by lazy { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +internal fun EpisodeDataAndDuration( + offsetDateTime: OffsetDateTime, + duration: Duration, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodySmall, +) { + Text( + text = stringResource( + R.string.episode_date_duration, + MediumDateFormatter.format(offsetDateTime), + duration.toMinutes().toInt() + ), + style = style, + modifier = modifier + ) +} 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 new file mode 100644 index 0000000000..e60359af6f --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ErrorState.kt @@ -0,0 +1,63 @@ +/* + * 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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +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.res.stringResource +import androidx.tv.material3.Button +import androidx.tv.material3.ExperimentalTvMaterial3Api +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.example.jetcaster.tv.R +import com.example.jetcaster.tv.ui.JetcasterAppDefaults + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +fun ErrorState( + backToHome: () -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() } +) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column { + Text( + text = stringResource(R.string.display_error_state), + style = MaterialTheme.typography.displayMedium + ) + Button( + onClick = backToHome, + modifier + .padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + .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/discover/DiscoverScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.kt index 57b2cf481b..8f7dcd4c34 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 @@ -33,6 +33,7 @@ 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.Podcast import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList @@ -43,6 +44,7 @@ import com.example.jetcaster.tv.ui.component.Loading @Composable fun DiscoverScreen( + showPodcastDetails: (Podcast) -> Unit, modifier: Modifier = Modifier, discoverScreenViewModel: DiscoverScreenViewModel = viewModel() ) { @@ -63,7 +65,7 @@ fun DiscoverScreen( podcastList = s.podcastList, selectedCategory = s.selectedCategory, latestEpisodeList = s.latestEpisodeList, - onPodcastSelected = discoverScreenViewModel::subscribe, + onPodcastSelected = { showPodcastDetails(it.podcast) }, onCategorySelected = discoverScreenViewModel::selectCategory, modifier = Modifier .fillMaxSize() 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 bd2d70ac09..e444620fb7 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 @@ -20,16 +20,21 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester 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.PodcastWithExtraInfo import com.example.jetcaster.tv.R import com.example.jetcaster.tv.ui.JetcasterAppDefaults import com.example.jetcaster.tv.ui.component.Catalog @@ -39,6 +44,7 @@ import com.example.jetcaster.tv.ui.component.Loading fun LibraryScreen( modifier: Modifier = Modifier, navigateToDiscover: () -> Unit, + showPodcastDetails: (PodcastWithExtraInfo) -> Unit, libraryScreenViewModel: LibraryScreenViewModel = viewModel() ) { val uiState by libraryScreenViewModel.uiState.collectAsState() @@ -51,7 +57,7 @@ fun LibraryScreen( is LibraryScreenUiState.Ready -> Catalog( podcastList = s.subscribedPodcastList, latestEpisodeList = s.latestEpisodeList, - onPodcastSelected = libraryScreenViewModel::unsubscribe, + onPodcastSelected = showPodcastDetails, modifier = modifier ) } @@ -63,6 +69,10 @@ private fun NavigateToDiscover( onNavigationRequested: () -> Unit, modifier: Modifier = Modifier, ) { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } Box(modifier = modifier, contentAlignment = Alignment.Center) { Column { Text( @@ -72,7 +82,9 @@ private fun NavigateToDiscover( Text(text = stringResource(id = R.string.message_no_subscribed_podcast)) Button( onClick = onNavigationRequested, - modifier = Modifier.padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + modifier = Modifier + .padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + .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/library/LibraryScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt index bc0fef41a5..07f624122a 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -18,7 +18,6 @@ package com.example.jetcaster.tv.ui.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.di.Graph import com.example.jetcaster.core.data.repository.EpisodeStore import com.example.jetcaster.core.data.repository.PodcastStore @@ -74,14 +73,6 @@ class LibraryScreenViewModel( LibraryScreenUiState.Loading ) - fun unsubscribe(podcast: PodcastWithExtraInfo) { - if (podcast.isFollowed) { - viewModelScope.launch { - podcastStore.togglePodcastFollowed(podcast.podcast.uri) - } - } - } - init { viewModelScope.launch { podcastsRepository.updatePodcasts(false) 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 new file mode 100644 index 0000000000..53d92a7512 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt @@ -0,0 +1,266 @@ +/* + * 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.podcast + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +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.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 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +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 +import androidx.tv.foundation.lazy.list.TvLazyColumn +import androidx.tv.foundation.lazy.list.items +import androidx.tv.material3.ButtonDefaults +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.JetcasterAppDefaults +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 + +@Composable +fun PodcastScreen( + podcastScreenViewModel: PodcastScreenViewModel, + backToHomeScreen: () -> Unit, + playEpisode: (Episode) -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by podcastScreenViewModel.uiStateFlow.collectAsState() + when (val s = uiState) { + PodcastScreenUiState.Loading -> Loading(modifier = modifier) + PodcastScreenUiState.Error -> ErrorState(backToHome = backToHomeScreen, modifier = modifier) + is PodcastScreenUiState.Ready -> PodcastDetails( + podcast = s.podcast, + episodeList = s.episodeList, + isSubscribed = s.isSubscribed, + subscribe = podcastScreenViewModel::subscribe, + unsubscribe = podcastScreenViewModel::unsubscribe, + playEpisode = playEpisode, + modifier = modifier, + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun PodcastDetails( + podcast: Podcast, + episodeList: EpisodeList, + isSubscribed: Boolean, + subscribe: (Podcast, Boolean) -> Unit, + unsubscribe: (Podcast, Boolean) -> Unit, + playEpisode: (Episode) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() } +) { + Row( + modifier = modifier.focusGroup(), + horizontalArrangement = + Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) + ) { + PodcastInfo( + podcast = podcast, + isSubscribed = isSubscribed, + subscribe = subscribe, + unsubscribe = unsubscribe, + modifier = Modifier.weight(1f), + ) + PodcastEpisodeList( + episodeList = episodeList, + onEpisodeSelected = { playEpisode(it.episode) }, + modifier = Modifier + .focusRequester(focusRequester) + .focusRestorer() + .weight(1f) + ) + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun PodcastInfo( + podcast: Podcast, + isSubscribed: Boolean, + subscribe: (Podcast, Boolean) -> Unit, + unsubscribe: (Podcast, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val author = podcast.author + 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) + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + if (author != null) { + Text( + text = author, + style = MaterialTheme.typography.bodySmall + ) + } + Text( + text = podcast.title, + style = MaterialTheme.typography.headlineSmall, + ) + if (description != null) { + Text( + text = description, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium + ) + } + ToggleSubscriptionButton( + podcast, + isSubscribed, + subscribe, + unsubscribe, + modifier = Modifier + .padding(top = JetcasterAppDefaults.gapSettings.catalogItemGap) + ) + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun ToggleSubscriptionButton( + podcast: Podcast, + isSubscribed: Boolean, + subscribe: (Podcast, Boolean) -> Unit, + unsubscribe: (Podcast, Boolean) -> Unit, + modifier: Modifier = Modifier +) { + val icon = if (isSubscribed) { + Icons.Default.Remove + } else { + Icons.Default.Add + } + val label = if (isSubscribed) { + stringResource(R.string.label_unsubscribe) + } else { + stringResource(R.string.label_subscribe) + } + val action = if (isSubscribed) { + unsubscribe + } else { + subscribe + } + ButtonWithIcon( + label = label, + icon = icon, + onClick = { action(podcast, isSubscribed) }, + scale = ButtonDefaults.scale(scale = 1f), + modifier = modifier + ) +} + +@Composable +private fun PodcastEpisodeList( + episodeList: EpisodeList, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier +) { + TvLazyColumn( + verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogItemGap), + modifier = modifier + ) { + items(episodeList) { + EpisodeListItem(episodeToPodcast = it, onEpisodeSelected = onEpisodeSelected) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeListItem( + episodeToPodcast: EpisodeToPodcast, + onEpisodeSelected: (EpisodeToPodcast) -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false +) { + ListItem( + selected = selected, + onClick = { onEpisodeSelected(episodeToPodcast) }, + modifier = modifier + ) { + Row( + modifier = Modifier.padding(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 16.dp) + ) { + EpisodeMetaData(episode = episodeToPodcast.episode) + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun EpisodeMetaData(episode: Episode, modifier: Modifier = Modifier) { + val published = episode.published + val duration = episode.duration + Column(modifier = modifier) { + Text( + text = episode.title, + style = MaterialTheme.typography.bodyMedium + ) + if (duration != null) { + EpisodeDataAndDuration(published, duration) + } + } +} 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 new file mode 100644 index 0000000000..5ea8d84690 --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreenViewModel.kt @@ -0,0 +1,121 @@ +/* + * 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.podcast + +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.jetcaster.core.data.database.model.Podcast +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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +class PodcastScreenViewModel( + handle: SavedStateHandle, + private val podcastStore: PodcastStore = Graph.podcastStore, + episodeStore: EpisodeStore = Graph.episodeStore, +) : ViewModel() { + + private val podcastUri = handle.get("podcastUri") ?: "uri://no/podcast/is/specified" + + private val podcastFlow = podcastStore.podcastWithUri(podcastUri).stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + null + ) + + @OptIn(ExperimentalCoroutinesApi::class) + private val episodeListFlow = podcastFlow.flatMapLatest { + if (it != null) { + episodeStore.episodesInPodcast(it.uri) + } else { + flowOf(emptyList()) + } + }.map { + EpisodeList(it) + } + + private val subscribedPodcastListFlow = podcastStore.followedPodcastsSortedByLastEpisode() + + val uiStateFlow = combine( + podcastFlow, + episodeListFlow, + subscribedPodcastListFlow + ) { podcast, episodeList, subscribedPodcastList -> + if (podcast != null) { + val isSubscribed = subscribedPodcastList.any { it.podcast.uri == podcastUri } + PodcastScreenUiState.Ready(podcast, episodeList, isSubscribed) + } else { + PodcastScreenUiState.Error + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5_000), + PodcastScreenUiState.Loading + ) + + fun subscribe(podcast: Podcast, isSubscribed: Boolean) { + if (!isSubscribed) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.uri) + } + } + } + + fun unsubscribe(podcast: Podcast, isSubscribed: Boolean) { + if (isSubscribed) { + viewModelScope.launch { + podcastStore.togglePodcastFollowed(podcast.uri) + } + } + } + + companion object { + @Suppress("UNCHECKED_CAST") + val factory = object : AbstractSavedStateViewModelFactory() { + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T { + return PodcastScreenViewModel( + handle + ) as T + } + } + } +} + +sealed interface PodcastScreenUiState { + data object Loading : PodcastScreenUiState + data object Error : PodcastScreenUiState + data class Ready( + val podcast: Podcast, + val episodeList: EpisodeList, + val isSubscribed: Boolean + ) : PodcastScreenUiState +} diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml index ea00a4daf9..0378e4d1ed 100644 --- a/Jetcaster/tv-app/src/main/res/values/strings.xml +++ b/Jetcaster/tv-app/src/main/res/values/strings.xml @@ -20,9 +20,11 @@ Loading Let\'s discover the podcasts! You subscribe no podcast yet. Let\'s discover the podcasts and subscribe them! + Something wrong happened Podcast Latest Episodes Subscribe + Unsubscribe Info Play Pause @@ -35,6 +37,7 @@ Episodes Latest Episodes Discover the podcasts + Back to Home Updated a while ago From 297db8c61f7769395cb9a7efa0d7903bf984136f Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Tue, 26 Mar 2024 16:36:06 +0900 Subject: [PATCH 5/7] Add background to the podcast details screen --- .../example/jetcaster/tv/ui/JetcasterApp.kt | 154 +++++++++--------- .../tv/ui/component/ButtonWithIcon.kt | 2 +- .../jetcaster/tv/ui/component/Catalog.kt | 4 +- .../tv/ui/library/LibraryScreenViewModel.kt | 2 +- .../jetcaster/tv/ui/podcast/PodcastScreen.kt | 63 ++++++- 5 files changed, 144 insertions(+), 81 deletions(-) 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 ff74c1bf7e..e97199174a 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 @@ -28,12 +28,8 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.VideoLibrary 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.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -56,21 +52,62 @@ import com.example.jetcaster.tv.ui.settings.SettingsScreen @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppState()) { - val discover = remember { FocusRequester() } + Route(jetcasterAppState = jetcasterAppState) +} + +internal data object JetcasterAppDefaults { + val overScanMargin = OverScanMarginSettings() + val gapSettings = GapSettings() + val cardWidth = CardWidth() + val padding = PaddingSettings() +} + +data class OverScanMarginSettings( + val default: OverScanMargin = OverScanMargin(), + val podcastDetails: OverScanMargin = OverScanMargin(top = 40.dp, bottom = 40.dp), + val drawer: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp), + val catalog: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp) +) + +data class OverScanMargin( + val top: Dp = 24.dp, + val bottom: Dp = 24.dp, + val start: Dp = 48.dp, + val end: Dp = 48.dp, +) { + fun intoPaddingValues(): PaddingValues { + return PaddingValues(start, top, end, bottom) + } +} + +data class CardWidth( + val large: Dp = 268.dp, + val medium: Dp = 196.dp, + val small: Dp = 124.dp +) +data class PaddingSettings( + val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), + val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp) +) + +data class GapSettings( + val catalogItemGap: Dp = 20.dp, + val catalogSectionGap: Dp = 40.dp, +) + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun WithGlobalNavigation( + jetcasterAppState: JetcasterAppState, + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { NavigationDrawer( drawerContent = { Column( modifier = Modifier - .padding( - JetcasterAppDefaults.overScanMargin - .copy( - start = 0.dp, - end = 0.dp - ) - .intoPaddingValues() - ) - .focusRestorer { discover } + .padding(JetcasterAppDefaults.overScanMargin.drawer.intoPaddingValues()) ) { NavigationDrawerItem( @@ -95,7 +132,6 @@ fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppStat selected = false, onClick = jetcasterAppState::navigateToDiscover, leadingContent = { Icon(Icons.Default.Home, contentDescription = null) }, - modifier = Modifier.focusRequester(discover) ) { Text(text = "Discover") } @@ -116,76 +152,46 @@ fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppStat } } }, - ) { - Route(jetcasterAppState = jetcasterAppState) - } + content = content, + modifier = modifier + ) } -internal data object JetcasterAppDefaults { - val overScanMargin = OverScanMargin() - val gapSettings = GapSettings() - val cardWidth = CardWidth() - val padding = PaddingSettings() -} - -data class OverScanMargin( - val top: Dp = 24.dp, - val bottom: Dp = 24.dp, - val start: Dp = 48.dp, - val end: Dp = 48.dp, -) { - fun intoPaddingValues(): PaddingValues { - return PaddingValues(start, top, end, bottom) - } -} - -data class CardWidth( - val large: Dp = 268.dp, - val medium: Dp = 196.dp, - val small: Dp = 124.dp -) - -data class PaddingSettings( - val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), - val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp) -) - -data class GapSettings( - val catalogItemGap: Dp = 20.dp, - val catalogSectionGap: Dp = 40.dp, -) - @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun Route(jetcasterAppState: JetcasterAppState) { NavHost(navController = jetcasterAppState.navHostController, Screen.Discover.route) { composable(Screen.Discover.route) { - DiscoverScreen( - showPodcastDetails = { - jetcasterAppState.showPodcastDetails(it.uri) - }, - modifier = Modifier - .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) - .fillMaxSize() - ) + WithGlobalNavigation(jetcasterAppState = jetcasterAppState) { + DiscoverScreen( + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.uri) + }, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) + .fillMaxSize() + ) + } } composable(Screen.Library.route) { - LibraryScreen( - navigateToDiscover = jetcasterAppState::navigateToDiscover, - showPodcastDetails = { - jetcasterAppState.showPodcastDetails(it.podcast.uri) - }, - modifier = Modifier - .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) - .fillMaxSize() - ) + WithGlobalNavigation(jetcasterAppState = jetcasterAppState) { + LibraryScreen( + navigateToDiscover = jetcasterAppState::navigateToDiscover, + showPodcastDetails = { + jetcasterAppState.showPodcastDetails(it.podcast.uri) + }, + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) + .fillMaxSize() + ) + } } composable(Screen.Search.route) { SearchScreen( modifier = Modifier - .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) .fillMaxSize() ) } @@ -199,7 +205,7 @@ private fun Route(jetcasterAppState: JetcasterAppState) { backToHomeScreen = jetcasterAppState::navigateToDiscover, playEpisode = {}, modifier = Modifier - .padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + .padding(JetcasterAppDefaults.overScanMargin.podcastDetails.intoPaddingValues()) .fillMaxSize(), ) } @@ -210,13 +216,15 @@ private fun Route(jetcasterAppState: JetcasterAppState) { composable(Screen.Profile.route) { ProfileScreen( - modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) ) } composable(Screen.Settings.route) { SettingsScreen( - modifier = Modifier.padding(JetcasterAppDefaults.overScanMargin.intoPaddingValues()) + modifier = Modifier + .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues()) ) } } diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt index f39f87e12f..b6ad6723da 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt @@ -34,8 +34,8 @@ internal fun ButtonWithIcon( label: String, icon: ImageVector, onClick: () -> Unit, - scale: ButtonScale = ButtonDefaults.scale(), modifier: Modifier = Modifier, + scale: ButtonScale = ButtonDefaults.scale(), ) { Button(onClick = onClick, modifier = modifier, scale = scale) { Icon( 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 9a31b2a456..045c76983f 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 @@ -60,9 +60,7 @@ internal fun Catalog( ) { TvLazyColumn( modifier = modifier, - contentPadding = JetcasterAppDefaults.overScanMargin - .copy(start = 0.dp, end = 0.dp) - .intoPaddingValues(), + contentPadding = JetcasterAppDefaults.overScanMargin.catalog.intoPaddingValues(), verticalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) ) { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt index 07f624122a..1855cbb192 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt @@ -36,7 +36,7 @@ import kotlinx.coroutines.launch class LibraryScreenViewModel( private val podcastsRepository: PodcastsRepository = Graph.podcastRepository, private val episodeStore: EpisodeStore = Graph.episodeStore, - private val podcastStore: PodcastStore = Graph.podcastStore, + podcastStore: PodcastStore = Graph.podcastStore, ) : ViewModel() { private val followingPodcastListFlow = podcastStore.followedPodcastsSortedByLastEpisode().map { 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 53d92a7512..252cabab5e 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 @@ -16,12 +16,13 @@ package com.example.jetcaster.tv.ui.podcast -import androidx.compose.foundation.focusGroup 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.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -37,9 +38,14 @@ import androidx.compose.runtime.remember 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 @@ -74,7 +80,7 @@ fun PodcastScreen( when (val s = uiState) { PodcastScreenUiState.Loading -> Loading(modifier = modifier) PodcastScreenUiState.Error -> ErrorState(backToHome = backToHomeScreen, modifier = modifier) - is PodcastScreenUiState.Ready -> PodcastDetails( + is PodcastScreenUiState.Ready -> PodcastDetailsWithBackground( podcast = s.podcast, episodeList = s.episodeList, isSubscribed = s.isSubscribed, @@ -86,6 +92,32 @@ fun PodcastScreen( } } +@Composable +private fun PodcastDetailsWithBackground( + podcast: Podcast, + episodeList: EpisodeList, + isSubscribed: Boolean, + subscribe: (Podcast, Boolean) -> Unit, + unsubscribe: (Podcast, Boolean) -> Unit, + playEpisode: (Episode) -> Unit, + modifier: Modifier = Modifier, + focusRequester: FocusRequester = remember { FocusRequester() } +) { + Box { + Background(podcast = podcast) + PodcastDetails( + podcast = podcast, + episodeList = episodeList, + isSubscribed = isSubscribed, + subscribe = subscribe, + unsubscribe = unsubscribe, + playEpisode = playEpisode, + focusRequester = focusRequester, + modifier = modifier + ) + } +} + @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable private fun PodcastDetails( @@ -99,7 +131,7 @@ private fun PodcastDetails( focusRequester: FocusRequester = remember { FocusRequester() } ) { Row( - modifier = modifier.focusGroup(), + modifier = modifier, horizontalArrangement = Arrangement.spacedBy(JetcasterAppDefaults.gapSettings.catalogSectionGap) ) { @@ -125,6 +157,31 @@ 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( From 4270387e26cc471b08e6750522406e91e5f22c86 Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Wed, 27 Mar 2024 09:50:25 +0900 Subject: [PATCH 6/7] Move the classes defining spaces, margins, and paddings in the app to Space.kt --- .../example/jetcaster/tv/model/EpisodeList.kt | 3 +- .../example/jetcaster/tv/ui/JetcasterApp.kt | 45 +------------- .../jetcaster/tv/ui/JetcasterAppState.kt | 6 +- .../jetcaster/tv/ui/component/Catalog.kt | 2 +- .../jetcaster/tv/ui/component/ErrorState.kt | 2 +- .../tv/ui/discover/DiscoverScreen.kt | 2 +- .../jetcaster/tv/ui/library/LibraryScreen.kt | 2 +- .../jetcaster/tv/ui/podcast/PodcastScreen.kt | 2 +- .../example/jetcaster/tv/ui/theme/Space.kt | 62 +++++++++++++++++++ 9 files changed, 74 insertions(+), 52 deletions(-) create mode 100644 Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt index c40bc87261..150a0de1b5 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt @@ -16,6 +16,7 @@ package com.example.jetcaster.tv.model +import androidx.compose.runtime.Immutable import com.example.jetcaster.core.data.database.model.EpisodeToPodcast - +@Immutable data class EpisodeList(val member: List) : List by member 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 e97199174a..211a786470 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 @@ -17,7 +17,6 @@ package com.example.jetcaster.tv.ui 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.padding @@ -30,8 +29,6 @@ import androidx.compose.material.icons.filled.VideoLibrary import androidx.compose.runtime.Composable import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -48,6 +45,7 @@ import com.example.jetcaster.tv.ui.podcast.PodcastScreenViewModel import com.example.jetcaster.tv.ui.profile.ProfileScreen import com.example.jetcaster.tv.ui.search.SearchScreen import com.example.jetcaster.tv.ui.settings.SettingsScreen +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable @@ -55,47 +53,6 @@ fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppStat Route(jetcasterAppState = jetcasterAppState) } -internal data object JetcasterAppDefaults { - val overScanMargin = OverScanMarginSettings() - val gapSettings = GapSettings() - val cardWidth = CardWidth() - val padding = PaddingSettings() -} - -data class OverScanMarginSettings( - val default: OverScanMargin = OverScanMargin(), - val podcastDetails: OverScanMargin = OverScanMargin(top = 40.dp, bottom = 40.dp), - val drawer: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp), - val catalog: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp) -) - -data class OverScanMargin( - val top: Dp = 24.dp, - val bottom: Dp = 24.dp, - val start: Dp = 48.dp, - val end: Dp = 48.dp, -) { - fun intoPaddingValues(): PaddingValues { - return PaddingValues(start, top, end, bottom) - } -} - -data class CardWidth( - val large: Dp = 268.dp, - val medium: Dp = 196.dp, - val small: Dp = 124.dp -) - -data class PaddingSettings( - val tab: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 6.dp), - val sectionTitle: PaddingValues = PaddingValues(bottom = 16.dp) -) - -data class GapSettings( - val catalogItemGap: Dp = 20.dp, - val catalogSectionGap: Dp = 40.dp, -) - @OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun WithGlobalNavigation( 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 600f2c6d16..23ac10a355 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 @@ -97,7 +97,8 @@ sealed interface Screen { companion object : Screen { private const val ROOT = "podcast" - override val route = "$ROOT/{podcastUri}" + private const val PARAMETER_NAME = "podcastUri" + override val route = "$ROOT/{$PARAMETER_NAME}" } } @@ -106,7 +107,8 @@ sealed interface Screen { companion object : Screen { private const val ROOT = "player" - override val route = "$ROOT/{episodeUri}" + private 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/Catalog.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt index 045c76983f..1654769daa 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 @@ -48,7 +48,7 @@ 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.JetcasterAppDefaults +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable internal fun Catalog( 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 e60359af6f..8e655a35b2 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 @@ -32,7 +32,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.example.jetcaster.tv.R -import com.example.jetcaster.tv.ui.JetcasterAppDefaults +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @OptIn(ExperimentalTvMaterial3Api::class) @Composable 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 8f7dcd4c34..3d84a42a6a 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 @@ -38,9 +38,9 @@ import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.tv.model.CategoryList import com.example.jetcaster.tv.model.EpisodeList import com.example.jetcaster.tv.model.PodcastList -import com.example.jetcaster.tv.ui.JetcasterAppDefaults import com.example.jetcaster.tv.ui.component.Catalog import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable fun DiscoverScreen( 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 e444620fb7..4f77143de5 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 @@ -36,9 +36,9 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.tv.R -import com.example.jetcaster.tv.ui.JetcasterAppDefaults import com.example.jetcaster.tv.ui.component.Catalog import com.example.jetcaster.tv.ui.component.Loading +import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults @Composable fun LibraryScreen( 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 252cabab5e..6d22f685d1 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 @@ -63,11 +63,11 @@ 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.JetcasterAppDefaults 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.theme.JetcasterAppDefaults @Composable fun PodcastScreen( 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 new file mode 100644 index 0000000000..ef5409312a --- /dev/null +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/theme/Space.kt @@ -0,0 +1,62 @@ +/* + * 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.theme + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +internal data object JetcasterAppDefaults { + val overScanMargin = OverScanMarginSettings() + val gapSettings = GapSettings() + val cardWidth = CardWidth() + val padding = PaddingSettings() +} + +internal data class OverScanMarginSettings( + val default: OverScanMargin = OverScanMargin(), + val podcastDetails: OverScanMargin = OverScanMargin(top = 40.dp, bottom = 40.dp), + val drawer: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp), + val catalog: OverScanMargin = OverScanMargin(start = 0.dp, end = 0.dp) +) + +internal data class OverScanMargin( + val top: Dp = 24.dp, + val bottom: Dp = 24.dp, + val start: Dp = 48.dp, + val end: Dp = 48.dp, +) { + fun intoPaddingValues(): PaddingValues { + return PaddingValues(start, top, end, bottom) + } +} + +internal data class CardWidth( + val large: Dp = 268.dp, + val medium: Dp = 196.dp, + val small: Dp = 124.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, +) From 2de5753a5ab87ef67ea0c3e630823e1fe18585bd Mon Sep 17 00:00:00 2001 From: Chiko Shimizu Date: Wed, 27 Mar 2024 12:25:32 +0900 Subject: [PATCH 7/7] Fix the failure of tv-app:minifyReleaseWithR8 task --- Jetcaster/tv-app/proguard-rules.pro | 33 +++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/Jetcaster/tv-app/proguard-rules.pro b/Jetcaster/tv-app/proguard-rules.pro index 481bb43481..08718bb52d 100644 --- a/Jetcaster/tv-app/proguard-rules.pro +++ b/Jetcaster/tv-app/proguard-rules.pro @@ -14,8 +14,37 @@ # Uncomment this to preserve the line number information for # debugging stack traces. -#-keepattributes SourceFile,LineNumberTable +-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +-renamesourcefileattribute SourceFile + +# Repackage classes into the top-level. +-repackageclasses + +# Rome reflectively loads classes referenced in com/rometools/rome/rome.properties. +-adaptresourcefilecontents com/rometools/rome/rome.properties +-keep,allowobfuscation class * implements com.rometools.rome.feed.synd.Converter +-keep,allowobfuscation class * implements com.rometools.rome.io.ModuleParser +-keep,allowobfuscation class * implements com.rometools.rome.io.WireFeedParser + +# Disable warnings for missing classes from OkHttp. +-dontwarn org.conscrypt.ConscryptHostnameVerifier + +# Disable warnings for missing classes from JDOM. +-dontwarn org.jaxen.DefaultNavigator +-dontwarn org.jaxen.NamespaceContext +-dontwarn org.jaxen.VariableContext + +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.slf4j.impl.StaticLoggerBinder +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE