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 5d1c536833..d748aaa616 100644
--- a/Jetcaster/tv-app/build.gradle.kts
+++ b/Jetcaster/tv-app/build.gradle.kts
@@ -70,11 +70,15 @@ 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(libs.coil.kt.compose)
+
implementation(project(":core"))
implementation(project(":designsystem"))
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
diff --git a/Jetcaster/tv-app/src/main/AndroidManifest.xml b/Jetcaster/tv-app/src/main/AndroidManifest.xml
index 6029cbc7c4..3ab2d935a4 100644
--- a/Jetcaster/tv-app/src/main/AndroidManifest.xml
+++ b/Jetcaster/tv-app/src/main/AndroidManifest.xml
@@ -24,20 +24,21 @@
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/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/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..150a0de1b5
--- /dev/null
+++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/model/EpisodeList.kt
@@ -0,0 +1,22 @@
+/*
+ * 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.EpisodeToPodcast
+@Immutable
+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
new file mode 100644
index 0000000000..211a786470
--- /dev/null
+++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterApp.kt
@@ -0,0 +1,188 @@
+/*
+ * 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.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
+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.lifecycle.viewmodel.compose.viewModel
+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.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
+import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
+
+@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
+@Composable
+fun JetcasterApp(jetcasterAppState: JetcasterAppState = rememberJetcasterAppState()) {
+ Route(jetcasterAppState = jetcasterAppState)
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun WithGlobalNavigation(
+ jetcasterAppState: JetcasterAppState,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit
+) {
+ NavigationDrawer(
+ drawerContent = {
+ Column(
+ modifier = Modifier
+ .padding(JetcasterAppDefaults.overScanMargin.drawer.intoPaddingValues())
+ ) {
+
+ 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) },
+ ) {
+ 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")
+ }
+ }
+ },
+ content = content,
+ modifier = modifier
+ )
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun Route(jetcasterAppState: JetcasterAppState) {
+ NavHost(navController = jetcasterAppState.navHostController, Screen.Discover.route) {
+ composable(Screen.Discover.route) {
+ WithGlobalNavigation(jetcasterAppState = jetcasterAppState) {
+ DiscoverScreen(
+ showPodcastDetails = {
+ jetcasterAppState.showPodcastDetails(it.uri)
+ },
+ modifier = Modifier
+ .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues())
+ .fillMaxSize()
+ )
+ }
+ }
+
+ composable(Screen.Library.route) {
+ 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.default.intoPaddingValues())
+ .fillMaxSize()
+ )
+ }
+
+ composable(Screen.Podcast.route) {
+ val podcastScreenViewModel: PodcastScreenViewModel = viewModel(
+ factory = PodcastScreenViewModel.factory
+ )
+ PodcastScreen(
+ podcastScreenViewModel = podcastScreenViewModel,
+ backToHomeScreen = jetcasterAppState::navigateToDiscover,
+ playEpisode = {},
+ modifier = Modifier
+ .padding(JetcasterAppDefaults.overScanMargin.podcastDetails.intoPaddingValues())
+ .fillMaxSize(),
+ )
+ }
+
+ composable(Screen.Player.route) {
+ Text(text = "Player")
+ }
+
+ composable(Screen.Profile.route) {
+ ProfileScreen(
+ modifier = Modifier
+ .padding(JetcasterAppDefaults.overScanMargin.default.intoPaddingValues())
+ )
+ }
+
+ composable(Screen.Settings.route) {
+ SettingsScreen(
+ modifier = Modifier
+ .padding(JetcasterAppDefaults.overScanMargin.default.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..23ac10a355
--- /dev/null
+++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/JetcasterAppState.kt
@@ -0,0 +1,114 @@
+/*
+ * 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 android.net.Uri
+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 encodedUrL = Uri.encode(podcastUri)
+ val screen = Screen.Podcast(encodedUrL)
+ 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 = "/discover"
+ }
+
+ 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 Podcast(private val podcastUri: String) : Screen {
+ override val route = "$ROOT/$podcastUri"
+
+ companion object : Screen {
+ private const val ROOT = "podcast"
+ private const val PARAMETER_NAME = "podcastUri"
+ override val route = "$ROOT/{$PARAMETER_NAME}"
+ }
+ }
+
+ data class Player(private val episodeUri: String) : Screen {
+ override val route = "$ROOT/$episodeUri"
+
+ companion object : Screen {
+ private const val ROOT = "player"
+ 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/ButtonWithIcon.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/ButtonWithIcon.kt
new file mode 100644
index 0000000000..b6ad6723da
--- /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,
+ modifier: Modifier = Modifier,
+ scale: ButtonScale = ButtonDefaults.scale(),
+) {
+ 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
new file mode 100644
index 0000000000..1654769daa
--- /dev/null
+++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/component/Catalog.kt
@@ -0,0 +1,269 @@
+/*
+ * 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.theme.JetcasterAppDefaults
+
+@Composable
+internal fun Catalog(
+ podcastList: PodcastList,
+ latestEpisodeList: EpisodeList,
+ onPodcastSelected: (PodcastWithExtraInfo) -> Unit,
+ modifier: Modifier = Modifier,
+ header: (@Composable () -> Unit)? = null,
+) {
+ TvLazyColumn(
+ modifier = modifier,
+ contentPadding = JetcasterAppDefaults.overScanMargin.catalog.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)
+ )
+ EpisodeDataAndDuration(offsetDateTime = publishedDate, duration = duration)
+ }
+ }
+}
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..8e655a35b2
--- /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.theme.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/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/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..3d84a42a6a
--- /dev/null
+++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreen.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 androidx.compose.foundation.layout.fillMaxSize
+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.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Tab
+import androidx.tv.material3.TabRow
+import androidx.tv.material3.Text
+import com.example.jetcaster.core.data.database.model.Category
+import com.example.jetcaster.core.data.database.model.Podcast
+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.component.Catalog
+import com.example.jetcaster.tv.ui.component.Loading
+import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
+
+@Composable
+fun DiscoverScreen(
+ showPodcastDetails: (Podcast) -> Unit,
+ 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 -> {
+ CatalogWithCategorySelection(
+ categoryList = s.categoryList,
+ podcastList = s.podcastList,
+ selectedCategory = s.selectedCategory,
+ latestEpisodeList = s.latestEpisodeList,
+ onPodcastSelected = { showPodcastDetails(it.podcast) },
+ onCategorySelected = discoverScreenViewModel::selectCategory,
+ modifier = Modifier
+ .fillMaxSize()
+ .then(modifier)
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
+@Composable
+private fun CatalogWithCategorySelection(
+ 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()
+ }
+
+ Catalog(
+ podcastList = podcastList,
+ latestEpisodeList = latestEpisodeList,
+ onPodcastSelected = onPodcastSelected,
+ modifier = modifier,
+ ) {
+ 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)
+ )
+ }
+ }
+ }
+ }
+}
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..911b92eb03
--- /dev/null
+++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/discover/DiscoverScreenViewModel.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.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
+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,
+ private val podcastStore: PodcastStore = Graph.podcastStore,
+) : 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
+ }
+
+ fun subscribe(podcastWithExtraInfo: PodcastWithExtraInfo) {
+ if (!podcastWithExtraInfo.isFollowed) {
+ viewModelScope.launch {
+ podcastStore.togglePodcastFollowed(podcastWithExtraInfo.podcast.uri)
+ }
+ }
+ }
+
+ 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/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..4f77143de5
--- /dev/null
+++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreen.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.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.component.Catalog
+import com.example.jetcaster.tv.ui.component.Loading
+import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults
+
+@Composable
+fun LibraryScreen(
+ modifier: Modifier = Modifier,
+ navigateToDiscover: () -> Unit,
+ showPodcastDetails: (PodcastWithExtraInfo) -> Unit,
+ libraryScreenViewModel: LibraryScreenViewModel = viewModel()
+) {
+ 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 = showPodcastDetails,
+ modifier = modifier
+ )
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun NavigateToDiscover(
+ onNavigationRequested: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ 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)
+ .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
new file mode 100644
index 0000000000..1855cbb192
--- /dev/null
+++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/library/LibraryScreenViewModel.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.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,
+ 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
+ )
+
+ 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/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..6d22f685d1
--- /dev/null
+++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/podcast/PodcastScreen.kt
@@ -0,0 +1,323 @@
+/*
+ * 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.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
+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.draw.drawWithCache
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.focusRestorer
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+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.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(
+ 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 -> PodcastDetailsWithBackground(
+ podcast = s.podcast,
+ episodeList = s.episodeList,
+ isSubscribed = s.isSubscribed,
+ subscribe = podcastScreenViewModel::subscribe,
+ unsubscribe = podcastScreenViewModel::unsubscribe,
+ playEpisode = playEpisode,
+ modifier = modifier,
+ )
+ }
+}
+
+@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(
+ 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,
+ 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()
+ }
+}
+
+@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(
+ 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/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/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,
+)
diff --git a/Jetcaster/tv-app/src/main/res/values/strings.xml b/Jetcaster/tv-app/src/main/res/values/strings.xml
index 0c76d4a991..0378e4d1ed 100644
--- a/Jetcaster/tv-app/src/main/res/values/strings.xml
+++ b/Jetcaster/tv-app/src/main/res/values/strings.xml
@@ -16,4 +16,39 @@
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!
+ Something wrong happened
+ Podcast
+ Latest Episodes
+ Subscribe
+ Unsubscribe
+ Info
+ Play
+ Pause
+ Skip 10 seconds
+ Rewind 10 seconds
+ Play the next episode
+ Play the previous episode
+ Listen
+ Podcasts
+ Episodes
+ Latest Episodes
+ Discover the podcasts
+ Back to Home
+
+ 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