diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt index 838e9eb71b..93a8d90115 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/JetcasterApp.kt @@ -57,9 +57,9 @@ fun JetcasterApp( ) ) PlayerScreen( - playerViewModel, windowSizeClass, displayFeatures, + playerViewModel, onBackPress = appState::navigateBack ) } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt index 27d73dd8bd..9654bd7211 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerScreen.kt @@ -19,6 +19,7 @@ package com.example.jetcaster.ui.player import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.basicMarquee +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -49,11 +50,11 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Replay10 import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.rounded.PauseCircleFilled import androidx.compose.material.icons.rounded.PlayCircleFilled import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.Surface @@ -62,6 +63,7 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -99,13 +101,21 @@ import java.time.Duration */ @Composable fun PlayerScreen( - viewModel: PlayerViewModel, windowSizeClass: WindowSizeClass, displayFeatures: List, + viewModel: PlayerViewModel, onBackPress: () -> Unit ) { val uiState = viewModel.uiState - PlayerScreen(uiState, windowSizeClass, displayFeatures, onBackPress) + PlayerScreen( + uiState, + windowSizeClass, + displayFeatures, + onBackPress, + onPlayPress = viewModel::onPlay, + onPausePress = viewModel::onPause, + onStop = viewModel::onStop + ) } /** @@ -117,11 +127,26 @@ private fun PlayerScreen( windowSizeClass: WindowSizeClass, displayFeatures: List, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, + onStop: () -> Unit, modifier: Modifier = Modifier ) { + DisposableEffect(Unit) { + onDispose { + onStop() + } + } Surface(modifier) { if (uiState.podcastName.isNotEmpty()) { - PlayerContent(uiState, windowSizeClass, displayFeatures, onBackPress) + PlayerContent( + uiState, + windowSizeClass, + displayFeatures, + onBackPress, + onPlayPress, + onPausePress + ) } else { FullScreenLoading() } @@ -134,6 +159,8 @@ fun PlayerContent( windowSizeClass: WindowSizeClass, displayFeatures: List, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, modifier: Modifier = Modifier ) { val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() @@ -162,7 +189,12 @@ fun PlayerContent( PlayerContentTableTopTop(uiState = uiState) }, second = { - PlayerContentTableTopBottom(uiState = uiState, onBackPress = onBackPress) + PlayerContentTableTopBottom( + uiState = uiState, + onBackPress = onBackPress, + onPlayPress = onPlayPress, + onPausePress = onPausePress + ) }, strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), displayFeatures = displayFeatures, @@ -186,7 +218,11 @@ fun PlayerContent( PlayerContentBookStart(uiState = uiState) }, second = { - PlayerContentBookEnd(uiState = uiState) + PlayerContentBookEnd( + uiState = uiState, + onPlayPress = onPlayPress, + onPausePress = onPausePress + ) }, strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f), displayFeatures = displayFeatures @@ -194,7 +230,13 @@ fun PlayerContent( } } } else { - PlayerContentRegular(uiState, onBackPress, modifier) + PlayerContentRegular( + uiState, + onBackPress, + onPlayPress, + onPausePress, + modifier + ) } } @@ -205,6 +247,8 @@ fun PlayerContent( private fun PlayerContentRegular( uiState: PlayerUiState, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, modifier: Modifier = Modifier ) { Column( @@ -235,8 +279,16 @@ private fun PlayerContentRegular( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(10f) ) { - PlayerSlider(uiState.duration) - PlayerButtons(Modifier.padding(vertical = 8.dp)) + PlayerSlider( + timeElapsed = uiState.timeElapsed, + episodeDuration = uiState.duration + ) + PlayerButtons( + isPlaying = uiState.isPlaying, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + Modifier.padding(vertical = 8.dp) + ) } Spacer(modifier = Modifier.weight(1f)) } @@ -279,6 +331,8 @@ private fun PlayerContentTableTopTop( private fun PlayerContentTableTopBottom( uiState: PlayerUiState, onBackPress: () -> Unit, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, modifier: Modifier = Modifier ) { // Content for the table part of the screen @@ -303,8 +357,17 @@ private fun PlayerContentTableTopBottom( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(10f) ) { - PlayerButtons(playerButtonSize = 92.dp, modifier = Modifier.padding(top = 8.dp)) - PlayerSlider(uiState.duration) + PlayerButtons( + isPlaying = uiState.isPlaying, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + playerButtonSize = 92.dp, + modifier = Modifier.padding(top = 8.dp) + ) + PlayerSlider( + timeElapsed = uiState.timeElapsed, + episodeDuration = uiState.duration + ) } } } @@ -344,6 +407,8 @@ private fun PlayerContentBookStart( @Composable private fun PlayerContentBookEnd( uiState: PlayerUiState, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, modifier: Modifier = Modifier ) { Column( @@ -359,8 +424,16 @@ private fun PlayerContentBookEnd( .padding(vertical = 16.dp) .weight(1f) ) - PlayerSlider(uiState.duration) - PlayerButtons(Modifier.padding(vertical = 8.dp)) + PlayerSlider( + timeElapsed = uiState.timeElapsed, + episodeDuration = uiState.duration + ) + PlayerButtons( + isPlaying = uiState.isPlaying, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + Modifier.padding(vertical = 8.dp) + ) } } @@ -462,25 +535,40 @@ private fun PodcastInformation( } } +fun Duration.formatString() : String { + val minutes = this.toMinutes().toString().padStart(2, '0') + val secondsLeft = (this.toSeconds() % 60).toString().padStart(2, '0') + return "$minutes:$secondsLeft" +} + @Composable -private fun PlayerSlider(episodeDuration: Duration?) { - if (episodeDuration != null) { - Column(Modifier.fillMaxWidth()) { - Slider(value = 0f, onValueChange = { }) - Row(Modifier.fillMaxWidth()) { - Text(text = "0s") - Spacer(modifier = Modifier.weight(1f)) - Text("${episodeDuration.seconds}s") - } +private fun PlayerSlider(timeElapsed: Duration?, episodeDuration: Duration?) { + Column(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth()) { + Text( + text = "${timeElapsed?.formatString()} • ${episodeDuration?.formatString()}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } + val sliderValue = (timeElapsed?.toSeconds() ?: 0).toFloat() + val maxRange = (episodeDuration?.toSeconds() ?: 0).toFloat() + Slider( + value = sliderValue, + valueRange = 0f..maxRange, + onValueChange = { } + ) } } @Composable private fun PlayerButtons( + isPlaying: Boolean, + onPlayPress: () -> Unit, + onPausePress: () -> Unit, modifier: Modifier = Modifier, playerButtonSize: Dp = 72.dp, - sideButtonSize: Dp = 48.dp + sideButtonSize: Dp = 48.dp, ) { Row( modifier = modifier.fillMaxWidth(), @@ -495,37 +583,55 @@ private fun PlayerButtons( imageVector = Icons.Filled.SkipPrevious, contentDescription = stringResource(R.string.cd_skip_previous), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), modifier = buttonsModifier ) Image( imageVector = Icons.Filled.Replay10, contentDescription = stringResource(R.string.cd_reply10), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), modifier = buttonsModifier ) - Image( - imageVector = Icons.Rounded.PlayCircleFilled, - contentDescription = stringResource(R.string.cd_play), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), - modifier = Modifier - .size(playerButtonSize) - .semantics { role = Role.Button } - ) + if (isPlaying) { + Image( + imageVector = Icons.Rounded.PauseCircleFilled, + contentDescription = stringResource(R.string.cd_pause), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primaryContainer), + modifier = Modifier + .size(playerButtonSize) + .semantics { role = Role.Button } + .clickable { + onPausePress() + } + ) + } else { + Image( + imageVector = Icons.Rounded.PlayCircleFilled, + contentDescription = stringResource(R.string.cd_play), + contentScale = ContentScale.Fit, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primaryContainer), + modifier = Modifier + .size(playerButtonSize) + .semantics { role = Role.Button } + .clickable { + onPlayPress() + } + ) + } Image( imageVector = Icons.Filled.Forward30, contentDescription = stringResource(R.string.cd_forward30), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), modifier = buttonsModifier ) Image( imageVector = Icons.Filled.SkipNext, contentDescription = stringResource(R.string.cd_skip_next), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(LocalContentColor.current), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), modifier = buttonsModifier ) } @@ -557,7 +663,11 @@ fun TopAppBarPreview() { @Composable fun PlayerButtonsPreview() { JetcasterTheme { - PlayerButtons() + PlayerButtons( + isPlaying = true, + onPlayPress = {}, + onPausePress = {} + ) } } @@ -574,11 +684,15 @@ fun PlayerScreenPreview() { PlayerUiState( title = "Title", duration = Duration.ofHours(2), - podcastName = "Podcast" + podcastName = "Podcast", + isPlaying = false, ), displayFeatures = emptyList(), windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(maxWidth, maxHeight)), - onBackPress = { } + onBackPress = { }, + onPlayPress = {}, + onPausePress = {}, + onStop = {}, ) } } diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt index 177104f714..e86c1b5c7f 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/player/PlayerViewModel.kt @@ -29,9 +29,11 @@ import androidx.savedstate.SavedStateRegistryOwner 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 java.time.Duration -import kotlinx.coroutines.flow.first +import com.example.jetcaster.core.player.EpisodePlayer +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import java.time.Duration data class PlayerUiState( val title: String = "", @@ -40,15 +42,18 @@ data class PlayerUiState( val podcastName: String = "", val author: String = "", val summary: String = "", - val podcastImageUrl: String = "" + val podcastImageUrl: String = "", + val isPlaying: Boolean = false, + val timeElapsed: Duration? = null, ) /** * ViewModel that handles the business logic and screen state of the Player screen */ class PlayerViewModel( - episodeStore: EpisodeStore, - podcastStore: PodcastStore, + episodeStore: EpisodeStore = Graph.episodeStore, + podcastStore: PodcastStore = Graph.podcastStore, + private val episodePlayer: EpisodePlayer = Graph.episodePlayer, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -61,25 +66,50 @@ class PlayerViewModel( init { viewModelScope.launch { - 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 ?: "" - ) + combine( + episodeStore.episodeAndPodcastWithUri(episodeUri).onEach { + if (episodePlayer.currentEpisode != it.episode) { + episodePlayer.addEpisode(it.episode) + } + }, + episodePlayer.playerState + ) { episodeToPlayer, playerState -> + PlayerUiState( + title = episodeToPlayer.episode.title, + duration = episodeToPlayer.episode.duration, + podcastName = episodeToPlayer.episode.title, + summary = episodeToPlayer.episode.summary ?: "", + podcastImageUrl = episodeToPlayer.podcast.imageUrl ?: "", + isPlaying = playerState.isPlaying, + timeElapsed = playerState.timeElapsed + ) + }.collect { + uiState = it + } } } + fun onPlay() { + episodePlayer.play() + } + + fun onPause() { + episodePlayer.pause() + } + + fun onStop() { + episodePlayer.stop() + } + /** - * Factory for PlayerViewModel that takes EpisodeStore and PodcastStore as a dependency + * Factory for PlayerViewModel that takes EpisodeStore, PodcastStore and EpisodePlayer as a + * dependency */ companion object { fun provideFactory( episodeStore: EpisodeStore = Graph.episodeStore, podcastStore: PodcastStore = Graph.podcastStore, + episodePlayer: EpisodePlayer = Graph.episodePlayer, owner: SavedStateRegistryOwner, defaultArgs: Bundle? = null, ): AbstractSavedStateViewModelFactory = @@ -90,7 +120,7 @@ class PlayerViewModel( modelClass: Class, handle: SavedStateHandle ): T { - return PlayerViewModel(episodeStore, podcastStore, handle) as T + return PlayerViewModel(episodeStore, podcastStore, episodePlayer, handle) as T } } } diff --git a/Jetcaster/app/src/main/res/values/strings.xml b/Jetcaster/app/src/main/res/values/strings.xml index 08bb67ca9c..66ddcbc16b 100644 --- a/Jetcaster/app/src/main/res/values/strings.xml +++ b/Jetcaster/app/src/main/res/values/strings.xml @@ -40,20 +40,21 @@ %1$s • %2$d mins - Search Account Add Back + Follow + Following + Forward 30 seconds More + Not following + Pause Play - Skip previous Reply 10 seconds - Forward 30 seconds + Search + Selected category Skip next + Skip previous Unfollow - Follow - Following - Not following - Selected category diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt index 4c4703a3b8..5943be5a7c 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/database/dao/EpisodesDao.kt @@ -36,6 +36,15 @@ abstract class EpisodesDao : BaseDao { ) abstract fun episode(uri: String): Flow + @Query( + """ + SELECT episodes.* FROM episodes + INNER JOIN podcasts ON episodes.podcast_uri = podcasts.uri + WHERE episodes.uri = :uri + """ + ) + abstract fun episodeAndPodcast(uri: String): Flow + @Query( """ SELECT * FROM episodes WHERE podcast_uri = :podcastUri diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt index 52e217a7c0..bc06f39dc6 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/di/Graph.kt @@ -31,13 +31,14 @@ import com.example.jetcaster.core.data.repository.LocalCategoryStore import com.example.jetcaster.core.data.repository.LocalEpisodeStore import com.example.jetcaster.core.data.repository.LocalPodcastStore import com.example.jetcaster.core.data.repository.PodcastsRepository +import com.example.jetcaster.core.player.MockEpisodePlayer import com.rometools.rome.io.SyndFeedInput -import java.io.File import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.LoggingEventListener +import java.io.File /** * A very simple global singleton dependency graph. @@ -55,6 +56,10 @@ object Graph { private val syndFeedInput by lazy { SyndFeedInput() } + val episodePlayer by lazy { + MockEpisodePlayer(mainDispatcher) + } + val podcastRepository by lazy { PodcastsRepository( podcastsFetcher = podcastFetcher, diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt index ef01e063a4..26af92e97c 100644 --- a/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/data/repository/EpisodeStore.kt @@ -27,6 +27,11 @@ interface EpisodeStore { */ fun episodeWithUri(episodeUri: String): Flow + /** + * Returns a flow containing the episode and corresponding podcast given an [episodeUri]. + */ + fun episodeAndPodcastWithUri(episodeUri: String): Flow + /** * Returns a flow containing the list of episodes associated with the podcast with the * given [podcastUri]. @@ -68,6 +73,9 @@ class LocalEpisodeStore( return episodesDao.episode(episodeUri) } + override fun episodeAndPodcastWithUri(episodeUri: String): Flow = + episodesDao.episodeAndPodcast(episodeUri) + /** * Returns a flow containing the list of episodes associated with the podcast with the * given [podcastUri]. diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt new file mode 100644 index 0000000000..b2b33c070d --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/EpisodePlayer.kt @@ -0,0 +1,81 @@ +package com.example.jetcaster.core.player + +import com.example.jetcaster.core.data.database.model.Episode +import kotlinx.coroutines.flow.StateFlow +import java.time.Duration + +data class EpisodePlayerState( + val currentEpisode: Episode? = null, + val episodeQueue: List = emptyList(), + val isPlaying: Boolean = false, + val timeElapsed: Duration = Duration.ZERO, +) + +/** + * Interface definition for an episode player defining high-level functions such as queuing + * episodes, playing an episode, pausing, seeking, etc. + */ +interface EpisodePlayer { + + /** + * A StateFlow that emits the [EpisodePlayerState] as controls as invoked on this player. + */ + val playerState: StateFlow + + /** + * Adds an episode to the queue. If the queue was previously empty, calling this will set the + * current episode to [episode]. + */ + fun addEpisode(episode: Episode) + + /** + * Returns the episode queue + */ + fun getQueue(): List + + /** + * Gets the current episode playing, or to be played, by this player. + */ + val currentEpisode: Episode? + + /** + * Sets the index of the episode to play in the queue [episodeIndex]. Must be a valid index. + */ + fun setEpisodeIndex(episodeIndex: Int) + + /** + * Plays the current episode + */ + fun play() + + /** + * Pauses the currently played episode + */ + fun pause() + + /** + * Stops the currently played episode + */ + fun stop() + + /** + * Plays another episode in the queue (if available) + */ + fun next() + + /** + * Plays the previous episode in the queue (if available). Or if an episode is currently + * playing this will start the episode from the beginning + */ + fun previous() + + /** + * Advances a currently played episode by a given time interval specified in [duration]. + */ + fun advanceBy(duration: Duration) + + /** + * Rewinds a currently played episode by a given time interval specified in [duration]. + */ + fun rewindBy(duration: Duration) +} diff --git a/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt new file mode 100644 index 0000000000..e102ccba6d --- /dev/null +++ b/Jetcaster/core/src/main/java/com/example/jetcaster/core/player/MockEpisodePlayer.kt @@ -0,0 +1,143 @@ +package com.example.jetcaster.core.player + +import com.example.jetcaster.core.data.database.model.Episode +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.time.Duration + +class MockEpisodePlayer( + private val mainDispatcher: CoroutineDispatcher +) : EpisodePlayer { + + private val _playerState = MutableStateFlow(EpisodePlayerState()) + private val _currentEpisode = MutableStateFlow(null) + private val queue = MutableStateFlow>(emptyList()) + private val isPlaying = MutableStateFlow(false) + private val timeElapsed = MutableStateFlow(Duration.ZERO) + private val coroutineScope = CoroutineScope(mainDispatcher) + + private var timerJob: Job? = null + init { + coroutineScope.launch { + // Combine streams here + combine( + _currentEpisode, + queue, + isPlaying, + timeElapsed + ) { currentEpisode, queue, isPlaying, timeElapsed -> + EpisodePlayerState( + currentEpisode = currentEpisode, + episodeQueue = queue, + isPlaying = isPlaying, + timeElapsed = timeElapsed + ) + }.catch { + // TODO handle error state + throw it + }.collect { + _playerState.value = it + } + } + } + + override val playerState: StateFlow = _playerState.asStateFlow() + + override val currentEpisode: Episode? + get() = _currentEpisode.value + override fun addEpisode(episode: Episode) { + queue.update { + it + episode + }.also { + if (_currentEpisode.value == null) { + _currentEpisode.value = episode + } + } + } + + override fun getQueue(): List = + queue.value + + override fun setEpisodeIndex(episodeIndex: Int) { + TODO("Not yet implemented") + } + + override fun play() { + // Do nothing if already playing + if (isPlaying.value) { + return + } + + val episode = _currentEpisode.value ?: return + + isPlaying.value = true + timerJob = coroutineScope.launch { + // Increment timer by a second + while (isActive && timeElapsed.value < episode.duration) { + delay(1000L) + timeElapsed.update { it + Duration.ofSeconds(1) } + } + + // Advance if there's another episode. Otherwise, stop playing + if (hasNext()) { + next() + } else { + timeElapsed.value = Duration.ZERO + isPlaying.value = false + } + } + } + + override fun pause() { + isPlaying.value = false + + timerJob?.cancel() + timerJob = null + } + + override fun stop() { + isPlaying.value = false + timeElapsed.value = Duration.ZERO + + timerJob?.cancel() + timerJob = null + } + + override fun advanceBy(duration: Duration) { + val currentEpisodeDuration = _currentEpisode.value?.duration ?: return + timeElapsed.update { + (it + duration).coerceAtMost(currentEpisodeDuration) + } + } + + override fun rewindBy(duration: Duration) { + timeElapsed.update { + (it - duration).coerceAtLeast(Duration.ZERO) + } + } + + override fun next() { + TODO("Not yet implemented") + } + + override fun previous() { + TODO("Not yet implemented") + } + + private fun hasNext(): Boolean { + val currentEpisode = _currentEpisode.value ?: return false + val queue = queue.value + val currentEpisodeIdx = queue.indexOf(currentEpisode) + return currentEpisodeIdx < queue.size - 1 + } +} diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestEpisodeStore.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestEpisodeStore.kt index 85c0da4c42..ec415eaa3c 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestEpisodeStore.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/repository/TestEpisodeStore.kt @@ -28,8 +28,19 @@ class TestEpisodeStore : EpisodeStore { private val episodesFlow = MutableStateFlow>(listOf()) override fun episodeWithUri(episodeUri: String): Flow = - episodesFlow.map { - it.first { it.uri == episodeUri } + episodesFlow.map { episodes -> + episodes.first { it.uri == episodeUri } + } + + override fun episodeAndPodcastWithUri(episodeUri: String): Flow = + episodesFlow.map { episodes -> + val e = episodes.first { + it.uri == episodeUri + } + EpisodeToPodcast().apply { + episode = e + _podcasts = emptyList() + } } override fun episodesInPodcast(podcastUri: String, limit: Int): Flow> =