diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt index 109810e3e0..4eeca1f437 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/model/PlayerEpisode.kt @@ -30,6 +30,7 @@ data class PlayerEpisode( val author: String = "", val summary: String = "", val podcastImageUrl: String = "", + val uri: String = "" ) { constructor(podcastInfo: PodcastInfo, episodeInfo: EpisodeInfo) : this( title = episodeInfo.title, @@ -39,6 +40,7 @@ data class PlayerEpisode( author = episodeInfo.author, summary = episodeInfo.summary, podcastImageUrl = podcastInfo.imageUrl, + uri = episodeInfo.uri ) } @@ -49,4 +51,5 @@ fun EpisodeToPodcast.toPlayerEpisode(): PlayerEpisode = podcastName = podcast.title, summary = episode.summary ?: "", podcastImageUrl = podcast.imageUrl ?: "", + uri = episode.uri ) diff --git a/Jetcaster/wear/build.gradle b/Jetcaster/wear/build.gradle index 1cb72263a6..72cf8f8436 100644 --- a/Jetcaster/wear/build.gradle +++ b/Jetcaster/wear/build.gradle @@ -55,6 +55,8 @@ android { } kotlinOptions { jvmTarget = JavaVersion.VERSION_17.majorVersion + freeCompilerArgs = freeCompilerArgs + "-opt-in=kotlin.RequiresOptIn" + freeCompilerArgs = freeCompilerArgs + "-opt-in=com.google.android.horologist.annotations.ExperimentalHorologistApi" } buildFeatures { compose true diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt index 33045b249d..ac1fd8d2eb 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/MainActivity.kt @@ -14,113 +14,22 @@ * limitations under the License. */ -@file:OptIn(ExperimentalHorologistApi::class, ExperimentalWearFoundationApi::class) - package com.example.jetcaster import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.wear.compose.foundation.ExperimentalWearFoundationApi -import androidx.wear.compose.material.TimeText -import androidx.wear.compose.navigation.composable -import androidx.wear.compose.navigation.rememberSwipeDismissableNavController -import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState -import com.example.jetcaster.theme.WearAppTheme -import com.example.jetcaster.ui.JetcasterNavController.navigateToLatestEpisode -import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext -import com.example.jetcaster.ui.JetcasterNavController.navigateToYourPodcast -import com.example.jetcaster.ui.LatestEpisodes -import com.example.jetcaster.ui.home.HomeScreen -import com.example.jetcaster.ui.library.LatestEpisodesScreen -import com.example.jetcaster.ui.player.PlayerScreen -import com.google.android.horologist.annotations.ExperimentalHorologistApi -import com.google.android.horologist.audio.ui.VolumeViewModel -import com.google.android.horologist.compose.layout.ScreenScaffold -import com.google.android.horologist.compose.layout.rememberColumnState -import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToVolume -import com.google.android.horologist.media.ui.navigation.MediaPlayerScaffold -import com.google.android.horologist.media.ui.snackbar.SnackbarManager -import com.google.android.horologist.media.ui.snackbar.SnackbarViewModel import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() super.onCreate(savedInstanceState) setContent { - installSplashScreen() WearApp() } } } - -@OptIn(ExperimentalHorologistApi::class) -@Composable -fun WearApp() { - - val navController = rememberSwipeDismissableNavController() - val navHostState = rememberSwipeDismissableNavHostState() - val volumeViewModel: VolumeViewModel = viewModel(factory = VolumeViewModel.Factory) - val snackBarManager: SnackbarManager = SnackbarManager() - val snackbarViewModel: SnackbarViewModel = SnackbarViewModel(snackBarManager) - - WearAppTheme { - MediaPlayerScaffold( - playerScreen = { - PlayerScreen( - modifier = Modifier.fillMaxSize(), - volumeViewModel = volumeViewModel, - onVolumeClick = { - navController.navigateToVolume() - }, - ) - }, - libraryScreen = { - HomeScreen( - onLatestEpisodeClick = { navController.navigateToLatestEpisode() }, - onYourPodcastClick = { navController.navigateToYourPodcast() }, - onUpNextClick = { navController.navigateToUpNext() }, - onErrorDialogCancelClick = { navController.popBackStack() } - ) - }, - categoryEntityScreen = { _, _ -> }, - mediaEntityScreen = {}, - playlistsScreen = {}, - settingsScreen = {}, - - navHostState = navHostState, - snackbarViewModel = snackbarViewModel, - volumeViewModel = volumeViewModel, - timeText = { - TimeText() - }, - deepLinkPrefix = "", - navController = navController, - additionalNavRoutes = { - composable( - route = LatestEpisodes.navRoute, - ) { - val columnState = rememberColumnState() - - ScreenScaffold(scrollState = columnState) { - LatestEpisodesScreen( - columnState = columnState, - playlistName = stringResource(id = R.string.latest_episodes), - onShuffleButtonClick = {}, - onPlayButtonClick = {} - ) - } - } - }, - - ) - } -} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt new file mode 100644 index 0000000000..6869c112c6 --- /dev/null +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/WearApp.kt @@ -0,0 +1,115 @@ +/* + * 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 + +/* + * Copyright 2022 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. + */ + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.wear.compose.navigation.composable +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController +import androidx.wear.compose.navigation.rememberSwipeDismissableNavHostState +import com.example.jetcaster.theme.WearAppTheme +import com.example.jetcaster.ui.JetcasterNavController.navigateToLatestEpisode +import com.example.jetcaster.ui.JetcasterNavController.navigateToUpNext +import com.example.jetcaster.ui.JetcasterNavController.navigateToYourPodcast +import com.example.jetcaster.ui.LatestEpisodes +import com.example.jetcaster.ui.home.HomeScreen +import com.example.jetcaster.ui.library.LatestEpisodesScreen +import com.example.jetcaster.ui.player.PlayerScreen +import com.google.android.horologist.audio.ui.VolumeViewModel +import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToPlayer +import com.google.android.horologist.media.ui.navigation.MediaNavController.navigateToVolume +import com.google.android.horologist.media.ui.navigation.MediaPlayerScaffold +import com.google.android.horologist.media.ui.snackbar.SnackbarManager +import com.google.android.horologist.media.ui.snackbar.SnackbarViewModel + +@Composable +fun WearApp() { + + val navController = rememberSwipeDismissableNavController() + val navHostState = rememberSwipeDismissableNavHostState() + val volumeViewModel: VolumeViewModel = viewModel(factory = VolumeViewModel.Factory) + + // TODO remove from MediaPlayerScaffold + val snackBarManager: SnackbarManager = SnackbarManager() + val snackbarViewModel: SnackbarViewModel = SnackbarViewModel(snackBarManager) + + WearAppTheme { + MediaPlayerScaffold( + playerScreen = { + PlayerScreen( + modifier = Modifier.fillMaxSize(), + volumeViewModel = volumeViewModel, + onVolumeClick = { + navController.navigateToVolume() + }, + ) + }, + libraryScreen = { + HomeScreen( + onLatestEpisodeClick = { navController.navigateToLatestEpisode() }, + onYourPodcastClick = { navController.navigateToYourPodcast() }, + onUpNextClick = { navController.navigateToUpNext() } + ) + }, + categoryEntityScreen = { _, _ -> }, + mediaEntityScreen = {}, + playlistsScreen = {}, + settingsScreen = {}, + + navHostState = navHostState, + snackbarViewModel = snackbarViewModel, + volumeViewModel = volumeViewModel, + deepLinkPrefix = "", + navController = navController, + additionalNavRoutes = { + composable( + route = LatestEpisodes.navRoute, + ) { + LatestEpisodesScreen( + playlistName = stringResource(id = R.string.latest_episodes), + onShuffleButtonClick = { + // navController.navigateToPlayer(it[0].episode.uri) + }, + onPlayButtonClick = { + navController.navigateToPlayer() + } + ) + } + }, + + ) + } +} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt index 041c3355d7..bc45f60d82 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeScreen.kt @@ -20,6 +20,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MusicNote import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter @@ -30,7 +33,6 @@ import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.Text import com.example.jetcaster.R import com.example.jetcaster.core.data.model.PodcastInfo -import com.google.android.horologist.annotations.ExperimentalHorologistApi import com.google.android.horologist.composables.PlaceholderChip import com.google.android.horologist.compose.layout.ScalingLazyColumn import com.google.android.horologist.compose.layout.ScalingLazyColumnDefaults @@ -44,15 +46,36 @@ import com.google.android.horologist.images.base.paintable.DrawableResPaintable import com.google.android.horologist.images.base.util.rememberVectorPainter import com.google.android.horologist.images.coil.CoilPaintable -@OptIn(ExperimentalHorologistApi::class) @Composable fun HomeScreen( onLatestEpisodeClick: () -> Unit, onYourPodcastClick: () -> Unit, onUpNextClick: () -> Unit, - onErrorDialogCancelClick: () -> Unit, modifier: Modifier = Modifier, homeViewModel: HomeViewModel = hiltViewModel(), +) { + val viewState by homeViewModel.uiState.collectAsStateWithLifecycle() + + HomeScreen( + modifier = modifier, + viewState = viewState, + onLatestEpisodeClick = onLatestEpisodeClick, + onYourPodcastClick = onYourPodcastClick, + onUpNextClick = onUpNextClick, + onTogglePodcastFollowed = { + homeViewModel.onTogglePodcastFollowed(it.uri) + }, + ) +} + +@Composable +fun HomeScreen( + viewState: HomeViewState, + onLatestEpisodeClick: () -> Unit, + onYourPodcastClick: () -> Unit, + onUpNextClick: () -> Unit, + onTogglePodcastFollowed: (PodcastInfo) -> Unit, + modifier: Modifier = Modifier, ) { val columnState = rememberResponsiveColumnState( contentPadding = ScalingLazyColumnDefaults.padding( @@ -60,93 +83,77 @@ fun HomeScreen( last = ScalingLazyColumnDefaults.ItemType.Chip, ), ) - val viewState by homeViewModel.state.collectAsStateWithLifecycle() + var haveDismissedDialog by remember { mutableStateOf(false) } ScreenScaffold(scrollState = columnState, modifier = modifier) { ScalingLazyColumn(columnState = columnState) { - if (viewState.featuredPodcasts.isNotEmpty()) { - item { - ResponsiveListHeader(modifier = Modifier.listTextPadding()) { - Text(stringResource(R.string.home_library)) - } - } - item { - Chip( - label = stringResource(R.string.latest_episodes), - onClick = onLatestEpisodeClick, - icon = DrawableResPaintable(R.drawable.new_releases), - colors = ChipDefaults.secondaryChipColors() - ) - } - item { - Chip( - label = stringResource(R.string.podcasts), - onClick = onYourPodcastClick, - icon = DrawableResPaintable(R.drawable.podcast), - colors = ChipDefaults.secondaryChipColors() - ) + item { + ResponsiveListHeader(modifier = Modifier.listTextPadding()) { + Text(stringResource(R.string.home_library)) } - item { - ResponsiveListHeader(modifier = Modifier.listTextPadding()) { - Text(stringResource(R.string.queue)) - } + } + item { + Chip( + label = stringResource(R.string.latest_episodes), + onClick = onLatestEpisodeClick, + icon = DrawableResPaintable(R.drawable.new_releases), + colors = ChipDefaults.secondaryChipColors() + ) + } + item { + Chip( + label = stringResource(R.string.podcasts), + onClick = onYourPodcastClick, + icon = DrawableResPaintable(R.drawable.podcast), + colors = ChipDefaults.secondaryChipColors() + ) + } + item { + ResponsiveListHeader(modifier = Modifier.listTextPadding()) { + Text(stringResource(R.string.queue)) } - item { - Chip( - label = stringResource(R.string.up_next), - onClick = onUpNextClick, - icon = DrawableResPaintable(R.drawable.up_next), - colors = ChipDefaults.secondaryChipColors() + } + item { + Chip( + label = stringResource(R.string.up_next), + onClick = onUpNextClick, + icon = DrawableResPaintable(R.drawable.up_next), + colors = ChipDefaults.secondaryChipColors() + ) + } + } + } + AlertDialog( + message = stringResource(R.string.entity_no_featured_podcasts), + showDialog = !haveDismissedDialog && viewState.featuredPodcasts.isEmpty(), + onDismiss = { haveDismissedDialog = true }, + + content = { + val podcast = viewState.podcastCategoryFilterResult.topPodcasts.first() + if (viewState.podcastCategoryFilterResult.topPodcasts.isNotEmpty()) { + items(viewState.podcastCategoryFilterResult.topPodcasts.take(1).size) { index -> + PodcastContent( + podcast = podcast, + downloadItemArtworkPlaceholder = rememberVectorPainter( + image = Icons.Default.MusicNote, + tintColor = Color.Blue, + ), + onClick = { + onTogglePodcastFollowed(podcast) + }, ) } } else { item { - AlertDialog( - message = stringResource(R.string.entity_no_featured_podcasts), - showDialog = true, - onDismiss = { onErrorDialogCancelClick }, - content = { - - if (viewState - .podcastCategoryFilterResult - .topPodcasts - .isNotEmpty() - ) { - item { - PodcastContent( - podcast = viewState.podcastCategoryFilterResult - .topPodcasts[0], - downloadItemArtworkPlaceholder = rememberVectorPainter( - image = Icons.Default.MusicNote, - tintColor = Color.Blue, - ), - onClick = { - homeViewModel.onTogglePodcastFollowed( - viewState - .podcastCategoryFilterResult - .topPodcasts[0] - .uri - ) - }, - ) - } - } else { - item { - PlaceholderChip( - contentDescription = "", - colors = ChipDefaults.secondaryChipColors() - ) - } - } - } + PlaceholderChip( + contentDescription = "", + colors = ChipDefaults.secondaryChipColors() ) } } } - } + ) } - -@OptIn(ExperimentalHorologistApi::class) @Composable private fun PodcastContent( podcast: PodcastInfo, diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt index 5bed543e20..b1851dc961 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/home/HomeViewModel.kt @@ -34,14 +34,12 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) @@ -62,76 +60,61 @@ class HomeViewModel @Inject constructor( private val homeCategories = MutableStateFlow(HomeCategory.entries) // Holds our currently selected category private val _selectedCategory = MutableStateFlow(null) - // Holds our view state which the UI collects via [state] - private val _state = MutableStateFlow(HomeViewState()) + // Holds the view state if the UI is refreshing for new data private val refreshing = MutableStateFlow(false) - val state: StateFlow - get() = _state - - init { - viewModelScope.launch { - // Combines the latest value from each of the flows, allowing us to generate a - // view state instance which only contains the latest values. - combine( - homeCategories, - selectedHomeCategory, - podcastStore.followedPodcastsSortedByLastEpisode(limit = 10), - refreshing, - _selectedCategory.flatMapLatest { selectedCategory -> - filterableCategoriesUseCase(selectedCategory) - }, - _selectedCategory.flatMapLatest { - podcastCategoryFilterUseCase(it) - }, - selectedLibraryPodcast.flatMapLatest { - episodeStore.episodesInPodcast( - podcastUri = it?.uri ?: "", - limit = 20 - ) - } - ) { homeCategories, - homeCategory, - podcasts, - refreshing, - filterableCategories, - podcastCategoryFilterResult, - libraryEpisodes -> - - _selectedCategory.value = filterableCategories.selectedCategory - - selectedHomeCategory.value = homeCategory - - HomeViewState( - homeCategories = homeCategories, - selectedHomeCategory = homeCategory, - featuredPodcasts = podcasts.toPersistentList(), - refreshing = refreshing, - filterableCategoriesModel = filterableCategories, - podcastCategoryFilterResult = podcastCategoryFilterResult, - libraryEpisodes = libraryEpisodes, - errorMessage = null, /* TODO */ - ) - }.catch { throwable -> - // TODO: emit a UI error here. For now we'll just rethrow - throw throwable - }.collect { - _state.value = it - } + // Combines the latest value from each of the flows, allowing us to generate a + // view state instance which only contains the latest values. + val uiState = combine( + homeCategories, + selectedHomeCategory, + podcastStore.followedPodcastsSortedByLastEpisode(limit = 10), + refreshing, + _selectedCategory.flatMapLatest { selectedCategory -> + filterableCategoriesUseCase(selectedCategory) + }, + _selectedCategory.flatMapLatest { + podcastCategoryFilterUseCase(it) + }, + selectedLibraryPodcast.flatMapLatest { + episodeStore.episodesInPodcast( + podcastUri = it?.uri ?: "", + limit = 20 + ) } + ) { homeCategories, + homeCategory, + podcasts, + refreshing, + filterableCategories, + podcastCategoryFilterResult, + libraryEpisodes -> + + _selectedCategory.value = filterableCategories.selectedCategory + + selectedHomeCategory.value = homeCategory + + HomeViewState( + homeCategories = homeCategories, + selectedHomeCategory = homeCategory, + featuredPodcasts = podcasts.toPersistentList(), + refreshing = refreshing, + filterableCategoriesModel = filterableCategories, + podcastCategoryFilterResult = podcastCategoryFilterResult, + libraryEpisodes = libraryEpisodes, + errorMessage = null, /* TODO */ + ) + }.stateIn(viewModelScope, SharingStarted.Lazily, initialValue = HomeViewState()) + init { refresh(force = false) } private fun refresh(force: Boolean) { viewModelScope.launch { - runCatching { - refreshing.value = true - podcastsRepository.updatePodcasts(force) - } - // TODO: look at result of runCatching and show any errors - + refreshing.value = true + podcastsRepository.updatePodcasts(force) refreshing.value = false } } @@ -166,7 +149,7 @@ enum class HomeCategory { } data class HomeViewState( - val featuredPodcasts: PersistentList = persistentListOf(), + val featuredPodcasts: List = listOf(), val refreshing: Boolean = false, val selectedHomeCategory: HomeCategory = HomeCategory.Discover, val homeCategories: List = emptyList(), diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt index 9a13538d06..2d17e695ea 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodeViewModel.kt @@ -20,6 +20,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.EpisodeToPodcast import com.example.jetcaster.core.data.domain.GetLatestFollowedEpisodesUseCase +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.util.combine import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -31,6 +33,7 @@ import kotlinx.coroutines.launch @HiltViewModel class LatestEpisodeViewModel @Inject constructor( private val episodesFromFavouritePodcasts: GetLatestFollowedEpisodesUseCase, + private val episodePlayer: EpisodePlayer, ) : ViewModel() { // Holds our view state which the UI collects via [state] private val _state = MutableStateFlow(LatestEpisodeViewState()) @@ -64,6 +67,9 @@ class LatestEpisodeViewModel @Inject constructor( } } } + fun onPlayEpisode(episode: PlayerEpisode) { + episodePlayer.currentEpisode = episode + } } data class LatestEpisodeViewState( val refreshing: Boolean = false, diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt index ba4879b2e7..58e4042aa7 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/library/LatestEpisodesScreen.kt @@ -38,9 +38,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.wear.compose.material.ChipDefaults import com.example.jetcaster.R import com.example.jetcaster.core.data.database.model.EpisodeToPodcast +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.data.model.toPlayerEpisode import com.google.android.horologist.annotations.ExperimentalHorologistApi -import com.google.android.horologist.compose.layout.ScalingLazyColumnState import com.google.android.horologist.compose.layout.ScreenScaffold +import com.google.android.horologist.compose.layout.rememberColumnState import com.google.android.horologist.compose.material.Button import com.google.android.horologist.compose.material.Chip import com.google.android.horologist.images.base.util.rememberVectorPainter @@ -48,23 +50,40 @@ import com.google.android.horologist.images.coil.CoilPaintable import com.google.android.horologist.media.ui.screens.entity.DefaultEntityScreenHeader import com.google.android.horologist.media.ui.screens.entity.EntityScreen -@OptIn(ExperimentalHorologistApi::class) -@Composable -public fun LatestEpisodesScreen( - columnState: ScalingLazyColumnState, +@Composable fun LatestEpisodesScreen( playlistName: String, - onShuffleButtonClick: (EpisodeToPodcast) -> Unit, - onPlayButtonClick: (EpisodeToPodcast) -> Unit, + onShuffleButtonClick: (List) -> Unit, + onPlayButtonClick: (List) -> Unit, modifier: Modifier = Modifier, latestEpisodeViewModel: LatestEpisodeViewModel = hiltViewModel() ) { val viewState by latestEpisodeViewModel.state.collectAsStateWithLifecycle() + LatestEpisodeScreen( + modifier = modifier, + playlistName = playlistName, + viewState = viewState, + onShuffleButtonClick = onShuffleButtonClick, + onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = latestEpisodeViewModel::onPlayEpisode + ) +} +@Composable +fun LatestEpisodeScreen( + playlistName: String, + viewState: LatestEpisodeViewState, + onShuffleButtonClick: (List) -> Unit, + onPlayButtonClick: (List) -> Unit, + modifier: Modifier = Modifier, + onPlayEpisode: (PlayerEpisode) -> Unit, +) { + val columnState = rememberColumnState() ScreenScaffold( scrollState = columnState, modifier = modifier ) { EntityScreen( + modifier = modifier, columnState = columnState, headerContent = { DefaultEntityScreenHeader(title = playlistName) }, content = { @@ -78,20 +97,20 @@ public fun LatestEpisodesScreen( ) } }, - modifier = modifier, buttonsContent = { ButtonsContent( + viewState = viewState, onShuffleButtonClick = onShuffleButtonClick, onPlayButtonClick = onPlayButtonClick, + onPlayEpisode = onPlayEpisode ) }, ) } } -@OptIn(ExperimentalHorologistApi::class) @Composable -private fun MediaContent( +fun MediaContent( episode: EpisodeToPodcast, downloadItemArtworkPlaceholder: Painter? ) { @@ -111,9 +130,11 @@ private fun MediaContent( @OptIn(ExperimentalHorologistApi::class) @Composable -private fun ButtonsContent( - onShuffleButtonClick: (EpisodeToPodcast) -> Unit, - onPlayButtonClick: (EpisodeToPodcast) -> Unit, +fun ButtonsContent( + viewState: LatestEpisodeViewState, + onShuffleButtonClick: (List) -> Unit, + onPlayButtonClick: (List) -> Unit, + onPlayEpisode: (PlayerEpisode) -> Unit ) { Row( @@ -126,7 +147,7 @@ private fun ButtonsContent( Button( imageVector = ImageVector.vectorResource(R.drawable.speed), contentDescription = stringResource(id = R.string.speed_button_content_description), - onClick = { /*onShuffleButtonClick(state.collectionModel)*/ }, + onClick = { onShuffleButtonClick(viewState.libraryEpisodes) }, modifier = Modifier .weight(weight = 0.3F, fill = false), ) @@ -134,7 +155,10 @@ private fun ButtonsContent( Button( imageVector = Icons.Filled.PlayArrow, contentDescription = stringResource(id = R.string.button_play_content_description), - onClick = { /*onPlayButtonClick(state.)*/ }, + onClick = { + onPlayButtonClick(viewState.libraryEpisodes) + onPlayEpisode(viewState.libraryEpisodes[0].toPlayerEpisode()) + }, modifier = Modifier .weight(weight = 0.3F, fill = false), ) diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 92263c45bb..b0bd7b4ded 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -36,25 +36,20 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalView import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.wear.compose.foundation.ExperimentalWearFoundationApi -import androidx.wear.compose.foundation.rememberActiveFocusRequester import androidx.wear.compose.material.MaterialTheme -import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.audio.ui.VolumeUiState import com.google.android.horologist.audio.ui.VolumeViewModel import com.google.android.horologist.audio.ui.rotaryVolumeControlsWithFocus import com.google.android.horologist.compose.rotaryinput.RotaryDefaults import com.google.android.horologist.media.ui.components.PodcastControlButtons import com.google.android.horologist.media.ui.components.background.ArtworkColorBackground +import com.google.android.horologist.media.ui.components.display.LoadingMediaDisplay import com.google.android.horologist.media.ui.components.display.TextMediaDisplay import com.google.android.horologist.media.ui.screens.player.PlayerScreen -import com.google.android.horologist.media.ui.state.PlayerUiController -import com.google.android.horologist.media.ui.state.PlayerUiState -@OptIn(ExperimentalHorologistApi::class, ExperimentalWearFoundationApi::class) @Composable fun PlayerScreen( volumeViewModel: VolumeViewModel, @@ -63,12 +58,36 @@ fun PlayerScreen( playerScreenViewModel: PlayerViewModel = hiltViewModel(), ) { val volumeUiState by volumeViewModel.volumeUiState.collectAsStateWithLifecycle() - // val settingsState by playerScreenViewModel.settingsState.collectAsStateWithLifecycle() - val focusRequester: FocusRequester = rememberActiveFocusRequester() + val playerUiState by playerScreenViewModel.uiState.collectAsStateWithLifecycle() + + PlayerScreen( + modifier = modifier, + playerUiState = playerUiState, + volumeUiState = volumeUiState, + onVolumeClick = onVolumeClick, + onUpdateVolume = { newVolume -> volumeViewModel.setVolume(newVolume) }, + ) +} + +@Composable +private fun PlayerScreen( + playerUiState: PlayerUiState?, + volumeUiState: VolumeUiState, + onVolumeClick: () -> Unit, + onUpdateVolume: (Int) -> Unit, + modifier: Modifier = Modifier, +) { PlayerScreen( mediaDisplay = { - playerScreenViewModel.uiState?.let { - TextMediaDisplay(title = it.podcastName, subtitle = it.subTitle) + if (playerUiState != null) { + playerUiState.episodePlayerState.currentEpisode?.let { + TextMediaDisplay( + title = it.podcastName, + subtitle = it.title + ) + } + } else { + LoadingMediaDisplay() } }, @@ -92,31 +111,20 @@ fun PlayerScreen( ) }, modifier = modifier.rotaryVolumeControlsWithFocus( - focusRequester = focusRequester, volumeUiStateProvider = { volumeUiState }, - onRotaryVolumeInput = { newVolume -> volumeViewModel.setVolume(newVolume) }, + onRotaryVolumeInput = onUpdateVolume, localView = LocalView.current, isLowRes = RotaryDefaults.isLowResInput(), ), background = { - val artworkUri = playerScreenViewModel.uiState.podcastImageUrl - ArtworkColorBackground( - artworkUri = artworkUri, - defaultColor = MaterialTheme.colors.primary, - modifier = Modifier.fillMaxSize(), - ) + if (playerUiState != null) { + val artworkUri = playerUiState.episodePlayerState.currentEpisode?.podcastImageUrl + ArtworkColorBackground( + artworkUri = artworkUri, + defaultColor = MaterialTheme.colors.primary, + modifier = Modifier.fillMaxSize(), + ) + } } ) } - -@OptIn(ExperimentalHorologistApi::class) -@Composable -fun PlayerScreenPodcastControlButtons( - playerUiController: PlayerUiController, - playerUiState: PlayerUiState, -) { - PodcastControlButtons( - playerController = playerUiController, - playerUiState = playerUiState, - ) -} diff --git a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 723b4a105e..d21ea47ce4 100644 --- a/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/wear/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -16,29 +16,18 @@ package com.example.jetcaster.ui.player -import android.net.Uri -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.jetcaster.core.data.repository.EpisodeStore -import com.example.jetcaster.core.data.repository.PodcastStore +import com.example.jetcaster.core.data.model.PlayerEpisode +import com.example.jetcaster.core.player.EpisodePlayer +import com.example.jetcaster.core.player.EpisodePlayerState import dagger.hilt.android.lifecycle.HiltViewModel -import java.time.Duration import javax.inject.Inject -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch data class PlayerUiState( - val title: String = "", - val subTitle: String = "", - val duration: Duration? = null, - val podcastName: String = "", - val author: String = "", - val summary: String = "", - val podcastImageUrl: String = "" + val episodePlayerState: EpisodePlayerState = EpisodePlayerState() ) /** @@ -46,34 +35,21 @@ data class PlayerUiState( */ @HiltViewModel class PlayerViewModel @Inject constructor( - episodeStore: EpisodeStore, - podcastStore: PodcastStore, - savedStateHandle: SavedStateHandle + private val episodePlayer: EpisodePlayer, ) : ViewModel() { - var uiState by mutableStateOf(PlayerUiState()) - private set + val uiState = MutableStateFlow(null) init { viewModelScope.launch { - if (savedStateHandle.get("episodeUri") != null) { - val episodeUri = Uri.decode(savedStateHandle.get("episodeUri")) - val episode = episodeStore.episodeWithUri(episodeUri).first() - val podcast = podcastStore.podcastWithUri(episode.podcastUri).first() - uiState = PlayerUiState( - title = episode.title, - duration = episode.duration, - podcastName = podcast.title, - summary = episode.summary ?: "", - podcastImageUrl = podcast.imageUrl ?: "" + val currentEpisode = episodePlayer.currentEpisode + if (currentEpisode != null) { + uiState.value = PlayerUiState( + episodePlayer.playerState.value ) } else { - uiState = PlayerUiState( - title = "", - duration = Duration.ZERO, - podcastName = "Nothing to play", - summary = "", - podcastImageUrl = "" + uiState.value = PlayerUiState( + EpisodePlayerState(currentEpisode = PlayerEpisode(title = "Nothing playing")) ) } }