From 3384c6fe5d635bdc50e02df56a60fe3e5c14ba1b Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Wed, 10 Apr 2024 15:17:55 -0700 Subject: [PATCH 1/6] [Jetcaster] Add unit test for mock episode player. --- .../com/example/jetcaster/ui/home/Home.kt | 2 +- .../jetcaster/ui/player/PlayerScreen.kt | 129 ++++++++++++------ .../jetcaster/ui/player/PlayerViewModel.kt | 6 + .../PodcastCategoryFilterUseCaseTest.kt | 2 +- .../core/player/MockEpisodePlayerTest.kt | 104 ++++++++++++++ .../tv/ui/episode/EpisodeScreenViewModel.kt | 2 +- .../jetcaster/tv/ui/player/PlayerScreen.kt | 2 +- 7 files changed, 205 insertions(+), 42 deletions(-) create mode 100644 Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt diff --git a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt index 8c4db45bec..3ed91cdd64 100644 --- a/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt +++ b/Jetcaster/app/src/main/java/com/example/jetcaster/ui/home/Home.kt @@ -454,7 +454,7 @@ private fun HomeContent( LaunchedEffect(pagerState, featuredPodcasts) { snapshotFlow { pagerState.currentPage } .collect { - val podcast = featuredPodcasts.getOrNull(pagerState.currentPage) + val podcast = featuredPodcasts.getOrNull(it) onLibraryPodcastSelected(podcast) } } 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 9008507cfa..242c874545 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 @@ -58,11 +58,15 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider -import androidx.compose.material3.Surface +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -101,6 +105,7 @@ import com.example.jetcaster.util.verticalGradientScrim import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.VerticalTwoPaneStrategy +import kotlinx.coroutines.launch import java.time.Duration /** @@ -115,10 +120,10 @@ fun PlayerScreen( ) { val uiState = viewModel.uiState PlayerScreen( - uiState, - windowSizeClass, - displayFeatures, - onBackPress, + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, onPlayPress = viewModel::onPlay, onPausePress = viewModel::onPause, onAdvanceBy = viewModel::onAdvanceBy, @@ -126,6 +131,7 @@ fun PlayerScreen( onStop = viewModel::onStop, onNext = viewModel::onNext, onPrevious = viewModel::onPrevious, + onAddToQueue = viewModel::onAddToQueue, ) } @@ -145,6 +151,7 @@ private fun PlayerScreen( onStop: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { DisposableEffect(Unit) { @@ -152,19 +159,35 @@ private fun PlayerScreen( onStop() } } - Surface(modifier) { + + val coroutineScope = rememberCoroutineScope() + val snackBarText = stringResource(id = R.string.episode_added_to_your_queue) + val snackbarHostState = remember { SnackbarHostState() } + Scaffold( + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, + modifier = modifier + ) { contentPadding -> if (uiState.episodePlayerState.currentEpisode != null) { PlayerContentWithBackground( - uiState, - windowSizeClass, - displayFeatures, - onBackPress, - onPlayPress, - onPausePress, - onAdvanceBy, - onRewindBy, - onNext, - onPrevious, + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, + onAddToQueue = { + coroutineScope.launch { + snackbarHostState.showSnackbar(snackBarText) + } + onAddToQueue() + }, + modifier = Modifier.padding(contentPadding) ) } else { FullScreenLoading() @@ -196,6 +219,7 @@ fun PlayerContentWithBackground( onRewindBy: (Duration) -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { Box(modifier = modifier, contentAlignment = Alignment.Center) { @@ -204,16 +228,17 @@ fun PlayerContentWithBackground( modifier = Modifier.fillMaxSize() ) PlayerContent( - uiState, - windowSizeClass, - displayFeatures, - onBackPress, - onPlayPress, - onPausePress, - onAdvanceBy, - onRewindBy, - onNext, - onPrevious, + uiState = uiState, + windowSizeClass = windowSizeClass, + displayFeatures = displayFeatures, + onBackPress = onBackPress, + onPlayPress = onPlayPress, + onPausePress = onPausePress, + onAdvanceBy = onAdvanceBy, + onRewindBy = onRewindBy, + onNext = onNext, + onPrevious = onPrevious, + onAddToQueue = onAddToQueue ) } } @@ -230,6 +255,7 @@ fun PlayerContent( onRewindBy: (Duration) -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { val foldingFeature = displayFeatures.filterIsInstance().firstOrNull() @@ -267,6 +293,7 @@ fun PlayerContent( onRewindBy = onRewindBy, onNext = onNext, onPrevious = onPrevious, + onAddToQueue = onAddToQueue, ) }, strategy = VerticalTwoPaneStrategy(splitFraction = 0.5f), @@ -285,7 +312,10 @@ fun PlayerContent( .systemBarsPadding() .padding(horizontal = 8.dp) ) { - TopAppBar(onBackPress = onBackPress) + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) TwoPane( first = { PlayerContentBookStart(uiState = uiState) @@ -308,15 +338,16 @@ fun PlayerContent( } } else { PlayerContentRegular( - uiState, - onBackPress, - onPlayPress, - onPausePress, + uiState = uiState, + onBackPress = onBackPress, + onPlayPress = onPlayPress, + onPausePress = onPausePress, onAdvanceBy = onAdvanceBy, onRewindBy = onRewindBy, onNext = onNext, onPrevious = onPrevious, - modifier, + onAddToQueue = onAddToQueue, + modifier = modifier, ) } } @@ -334,6 +365,7 @@ private fun PlayerContentRegular( onRewindBy: (Duration) -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { val playerEpisode = uiState.episodePlayerState @@ -349,7 +381,10 @@ private fun PlayerContentRegular( .systemBarsPadding() .padding(horizontal = 8.dp) ) { - TopAppBar(onBackPress = onBackPress) + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(horizontal = 8.dp) @@ -430,6 +465,7 @@ private fun PlayerContentTableTopBottom( onRewindBy: (Duration) -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, + onAddToQueue: () -> Unit, modifier: Modifier = Modifier ) { val episodePlayerState = uiState.episodePlayerState @@ -445,7 +481,10 @@ private fun PlayerContentTableTopBottom( .padding(horizontal = 32.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - TopAppBar(onBackPress = onBackPress) + TopAppBar( + onBackPress = onBackPress, + onAddToQueue = onAddToQueue, + ) PodcastDescription( title = episode.title, podcastName = episode.podcastName, @@ -551,7 +590,10 @@ private fun PlayerContentBookEnd( } @Composable -private fun TopAppBar(onBackPress: () -> Unit) { +private fun TopAppBar( + onBackPress: () -> Unit, + onAddToQueue: () -> Unit, +) { Row(Modifier.fillMaxWidth()) { IconButton(onClick = onBackPress) { Icon( @@ -560,7 +602,7 @@ private fun TopAppBar(onBackPress: () -> Unit) { ) } Spacer(Modifier.weight(1f)) - IconButton(onClick = { /* TODO */ }) { + IconButton(onClick = onAddToQueue) { Icon( imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = stringResource(R.string.cd_add) @@ -575,6 +617,13 @@ private fun TopAppBar(onBackPress: () -> Unit) { } } +@Composable +private fun PlayerCarousel( + modifier: Modifier = Modifier +) { + +} + @Composable private fun PlayerImage( podcastImageUrl: String, @@ -800,7 +849,10 @@ private fun HtmlText( @Composable fun TopAppBarPreview() { JetcasterTheme { - TopAppBar(onBackPress = { }) + TopAppBar( + onBackPress = {}, + onAddToQueue = {}, + ) } } @@ -849,7 +901,8 @@ fun PlayerScreenPreview() { onRewindBy = {}, onStop = {}, onNext = {}, - onPrevious = {} + onPrevious = {}, + onAddToQueue = {}, ) } } 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 73804e2177..9e18c86021 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 @@ -99,4 +99,10 @@ class PlayerViewModel @Inject constructor( fun onRewindBy(duration: Duration) { episodePlayer.rewindBy(duration) } + + fun onAddToQueue() { + uiState.episodePlayerState.currentEpisode?.let { + episodePlayer.addToQueue(it) + } + } } diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index 2f2d5a3b5b..d0cb16c0aa 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -24,12 +24,12 @@ import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.database.model.asExternalModel import com.example.jetcaster.core.data.database.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.TestCategoryStore -import java.time.OffsetDateTime import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.time.OffsetDateTime class PodcastCategoryFilterUseCaseTest { diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt new file mode 100644 index 0000000000..1b445bc025 --- /dev/null +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -0,0 +1,104 @@ +package com.example.jetcaster.core.player + +import com.example.jetcaster.core.model.PlayerEpisode +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Duration + +@OptIn(ExperimentalCoroutinesApi::class) +class MockEpisodePlayerTest { + + private val testDispatcher = StandardTestDispatcher() + private val mockEpisodePlayer = MockEpisodePlayer(testDispatcher) + private val testEpisodes = listOf( + PlayerEpisode( + uri = "uri1", + duration = Duration.ofSeconds(60) + ), + PlayerEpisode( + uri = "uri2", + duration = Duration.ofSeconds(60) + ), + PlayerEpisode( + uri = "uri3", + duration = Duration.ofSeconds(60) + ), + ) + + @Test + fun playerAutoPlaysNextEpisode() = runTest(testDispatcher) { + val duration = Duration.ofSeconds(60) + val currEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = duration + ) + mockEpisodePlayer.currentEpisode = currEpisode + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.play() + advanceTimeBy(duration.toMillis() + 1) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + } + + @Test + fun whenNextQueueEmpty_doesNothing() { + val episode = testEpisodes[0] + mockEpisodePlayer.currentEpisode = episode + mockEpisodePlayer.play() + + mockEpisodePlayer.next() + + assertEquals(episode, mockEpisodePlayer.currentEpisode) + } + + @Test + fun whenAddToQueue_queueNotEmpty() = runTest(testDispatcher) { + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + advanceUntilIdle() + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size, queue.size) + testEpisodes.forEachIndexed { index, playerEpisode -> + assertEquals(playerEpisode, queue[index]) + } + } + + @Test + fun whenNextQueueNotEmpty_removeFromQueue() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = Duration.ofSeconds(60) + ) + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } + + mockEpisodePlayer.play() + advanceTimeBy(100) + + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size - 1, queue.size) + } + + @Test + fun whenNextQueueNotEmpty_notRemovedFromQueue() { + } + + @Test + fun whenPreviousQueueEmpty_resetSameEpisode() { + } + + @Test + fun whenPreviousQueueNotEmpty_differentEpisode() { + } +} diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt index 9974d49952..3b96f178b1 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -26,7 +26,6 @@ import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.tv.ui.Screen import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest @@ -34,6 +33,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt index dd3047d44d..355728ac1e 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -78,9 +78,9 @@ import com.example.jetcaster.tv.ui.component.RewindButton import com.example.jetcaster.tv.ui.component.Seekbar import com.example.jetcaster.tv.ui.component.SkipButton import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults -import java.time.Duration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import java.time.Duration @Composable fun PlayerScreen( From bef840d795f1a9757093ccf817a5841f521c95f8 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 12 Apr 2024 09:11:24 -0700 Subject: [PATCH 2/6] Remove unused code. --- .../jetcaster/ui/player/PlayerScreen.kt | 80 +++++++++++-------- .../jetcaster/ui/player/PlayerViewModel.kt | 9 ++- .../core/player/MockEpisodePlayerTest.kt | 30 +++++-- .../designsystem/component/ImageBackground.kt | 2 +- 4 files changed, 80 insertions(+), 41 deletions(-) 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 242c874545..c5a00fd48f 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 @@ -18,6 +18,7 @@ package com.example.jetcaster.ui.player import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -42,6 +43,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -51,8 +53,8 @@ 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.material.icons.outlined.Pause +import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -202,7 +204,7 @@ private fun PlayerBackground( ) { ImageBackgroundColorScrim( url = episode?.podcastImageUrl, - color = Color.Black.copy(alpha = 0.68f), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), modifier = modifier, ) } @@ -238,7 +240,7 @@ fun PlayerContentWithBackground( onRewindBy = onRewindBy, onNext = onNext, onPrevious = onPrevious, - onAddToQueue = onAddToQueue + onAddToQueue = onAddToQueue, ) } } @@ -281,7 +283,9 @@ fun PlayerContent( if (usingVerticalStrategy) { TwoPane( first = { - PlayerContentTableTopTop(uiState = uiState) + PlayerContentTableTopTop( + uiState = uiState, + ) }, second = { PlayerContentTableTopBottom( @@ -617,13 +621,6 @@ private fun TopAppBar( } } -@Composable -private fun PlayerCarousel( - modifier: Modifier = Modifier -) { - -} - @Composable private fun PlayerImage( podcastImageUrl: String, @@ -654,11 +651,13 @@ private fun PodcastDescription( text = title, style = titleTextStyle, maxLines = 1, + color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.basicMarquee() ) Text( text = podcastName, style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, maxLines = 1 ) } @@ -742,50 +741,60 @@ private fun PlayerButtons( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly ) { - val buttonsModifier = Modifier + val sideButtonsModifier = Modifier .size(sideButtonSize) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = CircleShape + ) + .semantics { role = Role.Button } + + val primaryButtonModifier = Modifier + .size(playerButtonSize) + .background( + color = MaterialTheme.colorScheme.primaryContainer, + shape = CircleShape + ) .semantics { role = Role.Button } Image( imageVector = Icons.Filled.SkipPrevious, contentDescription = stringResource(R.string.cd_skip_previous), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), - modifier = buttonsModifier + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant), + modifier = sideButtonsModifier .clickable(enabled = isPlaying, onClick = onPrevious) ) Image( imageVector = Icons.Filled.Replay10, contentDescription = stringResource(R.string.cd_replay10), - contentScale = ContentScale.Fit, + contentScale = ContentScale.Inside, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), - modifier = buttonsModifier + modifier = sideButtonsModifier .clickable { onRewindBy(Duration.ofSeconds(10)) } ) if (isPlaying) { Image( - imageVector = Icons.Rounded.PauseCircleFilled, + imageVector = Icons.Outlined.Pause, contentDescription = stringResource(R.string.cd_pause), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primaryContainer), - modifier = Modifier - .size(playerButtonSize) - .semantics { role = Role.Button } + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer), + modifier = primaryButtonModifier + .padding(8.dp) .clickable { onPausePress() } ) } else { Image( - imageVector = Icons.Rounded.PlayCircleFilled, + imageVector = Icons.Outlined.PlayArrow, contentDescription = stringResource(R.string.cd_play), contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primaryContainer), - modifier = Modifier - .size(playerButtonSize) - .semantics { role = Role.Button } + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onPrimaryContainer), + modifier = primaryButtonModifier + .padding(8.dp) .clickable { onPlayPress() } @@ -794,9 +803,9 @@ private fun PlayerButtons( Image( imageVector = Icons.Filled.Forward10, contentDescription = stringResource(R.string.cd_forward10), - contentScale = ContentScale.Fit, + contentScale = ContentScale.Inside, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), - modifier = buttonsModifier + modifier = sideButtonsModifier .clickable { onAdvanceBy(Duration.ofSeconds(10)) } @@ -804,9 +813,9 @@ private fun PlayerButtons( Image( imageVector = Icons.Filled.SkipNext, contentDescription = stringResource(R.string.cd_skip_next), - contentScale = ContentScale.Fit, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), - modifier = buttonsModifier + contentScale = ContentScale.Inside, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant), + modifier = sideButtonsModifier .clickable(enabled = hasNext, onClick = onNext) ) } @@ -890,6 +899,11 @@ fun PlayerScreenPreview() { podcastName = "Podcast", ), isPlaying = false, + queue = listOf( + PlayerEpisode(), + PlayerEpisode(), + PlayerEpisode(), + ) ), ), displayFeatures = emptyList(), 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 9e18c86021..a6d0c99591 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 @@ -25,16 +25,17 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore +import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.ui.Screen import dagger.hilt.android.lifecycle.HiltViewModel -import java.time.Duration -import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.time.Duration +import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() @@ -105,4 +106,8 @@ class PlayerViewModel @Inject constructor( episodePlayer.addToQueue(it) } } + + fun onPlay(episode: PlayerEpisode) { + episodePlayer.play(episode) + } } diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt index 1b445bc025..53e7d54721 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -91,14 +91,34 @@ class MockEpisodePlayerTest { } @Test - fun whenNextQueueNotEmpty_notRemovedFromQueue() { - } + fun whenNextQueueNotEmpty_notRemovedFromQueue() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = PlayerEpisode( + uri = "currentEpisode", + duration = Duration.ofSeconds(60) + ) + testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } - @Test - fun whenPreviousQueueEmpty_resetSameEpisode() { + mockEpisodePlayer.play() + advanceTimeBy(100) + + // TODO override next? + mockEpisodePlayer.next() + advanceTimeBy(100) + + assertEquals(testEpisodes.first(), mockEpisodePlayer.currentEpisode) + + val queue = mockEpisodePlayer.playerState.value.queue + assertEquals(testEpisodes.size - 1, queue.size) } @Test - fun whenPreviousQueueNotEmpty_differentEpisode() { + fun whenPreviousQueueEmpty_resetSameEpisode() = runTest(testDispatcher) { + mockEpisodePlayer.currentEpisode = testEpisodes[0] + mockEpisodePlayer.play() + advanceTimeBy(1000L) + + mockEpisodePlayer.previous() + assertEquals(0, mockEpisodePlayer.playerState.value.timeElapsed.toMillis()) + assertEquals(testEpisodes[0], mockEpisodePlayer.currentEpisode) } } diff --git a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt index 83670bf6a5..4cb124dc65 100644 --- a/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt +++ b/Jetcaster/designsystem/src/main/java/com/example/jetcaster/designsystem/component/ImageBackground.kt @@ -38,7 +38,7 @@ fun ImageBackgroundColorScrim( url = url, modifier = modifier, overlay = { - drawRect(color, blendMode = BlendMode.Multiply) + drawRect(color) } ) } From 23a9f32ed8639f51769b58ec63c02fcf0d652b13 Mon Sep 17 00:00:00 2001 From: arriolac Date: Fri, 12 Apr 2024 20:04:43 +0000 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jetcaster/ui/player/PlayerScreen.kt | 4 ++-- .../jetcaster/ui/player/PlayerViewModel.kt | 4 ++-- .../domain/PodcastCategoryFilterUseCaseTest.kt | 2 +- .../core/player/MockEpisodePlayerTest.kt | 18 +++++++++++++++++- .../tv/ui/episode/EpisodeScreenViewModel.kt | 2 +- .../jetcaster/tv/ui/player/PlayerScreen.kt | 2 +- 6 files changed, 24 insertions(+), 8 deletions(-) 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 c5a00fd48f..26904fbe1d 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 @@ -42,8 +42,8 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -107,8 +107,8 @@ import com.example.jetcaster.util.verticalGradientScrim import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy import com.google.accompanist.adaptive.TwoPane import com.google.accompanist.adaptive.VerticalTwoPaneStrategy -import kotlinx.coroutines.launch import java.time.Duration +import kotlinx.coroutines.launch /** * Stateful version of the Podcast player 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 a6d0c99591..edf1cc06b5 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 @@ -30,12 +30,12 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.ui.Screen import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Duration +import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import java.time.Duration -import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt index d0cb16c0aa..2f2d5a3b5b 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/data/domain/PodcastCategoryFilterUseCaseTest.kt @@ -24,12 +24,12 @@ import com.example.jetcaster.core.data.database.model.PodcastWithExtraInfo import com.example.jetcaster.core.data.database.model.asExternalModel import com.example.jetcaster.core.data.database.model.asPodcastCategoryEpisode import com.example.jetcaster.core.data.repository.TestCategoryStore +import java.time.OffsetDateTime import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import java.time.OffsetDateTime class PodcastCategoryFilterUseCaseTest { diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt index 53e7d54721..9bd48e35da 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -1,6 +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.core.player import com.example.jetcaster.core.model.PlayerEpisode +import java.time.Duration import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -8,7 +25,6 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test -import java.time.Duration @OptIn(ExperimentalCoroutinesApi::class) class MockEpisodePlayerTest { diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt index 3b96f178b1..9974d49952 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/episode/EpisodeScreenViewModel.kt @@ -26,6 +26,7 @@ import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.tv.ui.Screen import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest @@ -33,7 +34,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject @HiltViewModel class EpisodeScreenViewModel @Inject constructor( diff --git a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt index 355728ac1e..dd3047d44d 100644 --- a/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt +++ b/Jetcaster/tv-app/src/main/java/com/example/jetcaster/tv/ui/player/PlayerScreen.kt @@ -78,9 +78,9 @@ import com.example.jetcaster.tv.ui.component.RewindButton import com.example.jetcaster.tv.ui.component.Seekbar import com.example.jetcaster.tv.ui.component.SkipButton import com.example.jetcaster.tv.ui.theme.JetcasterAppDefaults +import java.time.Duration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import java.time.Duration @Composable fun PlayerScreen( From e369aa8037be99613560c527fd4ea17e287e668f Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 12 Apr 2024 13:20:49 -0700 Subject: [PATCH 4/6] Remove unused method. --- .../java/com/example/jetcaster/ui/player/PlayerViewModel.kt | 5 ----- 1 file changed, 5 deletions(-) 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 edf1cc06b5..9e18c86021 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 @@ -25,7 +25,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.jetcaster.core.data.database.model.toPlayerEpisode import com.example.jetcaster.core.data.repository.EpisodeStore -import com.example.jetcaster.core.model.PlayerEpisode import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.ui.Screen @@ -106,8 +105,4 @@ class PlayerViewModel @Inject constructor( episodePlayer.addToQueue(it) } } - - fun onPlay(episode: PlayerEpisode) { - episodePlayer.play(episode) - } } From 0d8140a6bfdc5cccfa14dcd7e2c8b4fb31b2c244 Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Fri, 12 Apr 2024 14:54:12 -0700 Subject: [PATCH 5/6] PR feedback. --- .../jetcaster/ui/player/PlayerViewModel.kt | 4 ++-- .../core/player/MockEpisodePlayerTest.kt | 15 +++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) 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 9e18c86021..279d636da1 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,12 +29,12 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.ui.Screen import dagger.hilt.android.lifecycle.HiltViewModel -import java.time.Duration -import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.time.Duration +import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt index 9bd48e35da..de3f5d6989 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -17,7 +17,6 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.model.PlayerEpisode -import java.time.Duration import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -25,6 +24,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test +import java.time.Duration @OptIn(ExperimentalCoroutinesApi::class) class MockEpisodePlayerTest { @@ -47,7 +47,7 @@ class MockEpisodePlayerTest { ) @Test - fun playerAutoPlaysNextEpisode() = runTest(testDispatcher) { + fun whenPlayDone_playerAutoPlaysNextEpisode() = runTest(testDispatcher) { val duration = Duration.ofSeconds(60) val currEpisode = PlayerEpisode( uri = "currentEpisode", @@ -63,7 +63,7 @@ class MockEpisodePlayerTest { } @Test - fun whenNextQueueEmpty_doesNothing() { + fun whenNext_queueIsEmpty_doesNothing() { val episode = testEpisodes[0] mockEpisodePlayer.currentEpisode = episode mockEpisodePlayer.play() @@ -74,7 +74,7 @@ class MockEpisodePlayerTest { } @Test - fun whenAddToQueue_queueNotEmpty() = runTest(testDispatcher) { + fun whenAddToQueue_queueIsNotEmpty() = runTest(testDispatcher) { testEpisodes.forEach { mockEpisodePlayer.addToQueue(it) } advanceUntilIdle() @@ -87,7 +87,7 @@ class MockEpisodePlayerTest { } @Test - fun whenNextQueueNotEmpty_removeFromQueue() = runTest(testDispatcher) { + fun whenNext_queueIsNotEmpty_removeFromQueue() = runTest(testDispatcher) { mockEpisodePlayer.currentEpisode = PlayerEpisode( uri = "currentEpisode", duration = Duration.ofSeconds(60) @@ -107,7 +107,7 @@ class MockEpisodePlayerTest { } @Test - fun whenNextQueueNotEmpty_notRemovedFromQueue() = runTest(testDispatcher) { + fun whenNext_queueIsNotEmpty_notRemovedFromQueue() = runTest(testDispatcher) { mockEpisodePlayer.currentEpisode = PlayerEpisode( uri = "currentEpisode", duration = Duration.ofSeconds(60) @@ -117,7 +117,6 @@ class MockEpisodePlayerTest { mockEpisodePlayer.play() advanceTimeBy(100) - // TODO override next? mockEpisodePlayer.next() advanceTimeBy(100) @@ -128,7 +127,7 @@ class MockEpisodePlayerTest { } @Test - fun whenPreviousQueueEmpty_resetSameEpisode() = runTest(testDispatcher) { + fun whenPrevious_queueIsEmpty_resetSameEpisode() = runTest(testDispatcher) { mockEpisodePlayer.currentEpisode = testEpisodes[0] mockEpisodePlayer.play() advanceTimeBy(1000L) From 7f8062e487143dd6b07120700a43ae2aec4e023f Mon Sep 17 00:00:00 2001 From: arriolac Date: Fri, 12 Apr 2024 21:56:41 +0000 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20Apply=20Spotless?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/example/jetcaster/ui/player/PlayerViewModel.kt | 4 ++-- .../example/jetcaster/core/player/MockEpisodePlayerTest.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 279d636da1..9e18c86021 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,12 +29,12 @@ import com.example.jetcaster.core.player.EpisodePlayer import com.example.jetcaster.core.player.EpisodePlayerState import com.example.jetcaster.ui.Screen import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Duration +import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import java.time.Duration -import javax.inject.Inject data class PlayerUiState( val episodePlayerState: EpisodePlayerState = EpisodePlayerState() diff --git a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt index de3f5d6989..7b65b05c40 100644 --- a/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt +++ b/Jetcaster/core/src/test/kotlin/com/example/jetcaster/core/player/MockEpisodePlayerTest.kt @@ -17,6 +17,7 @@ package com.example.jetcaster.core.player import com.example.jetcaster.core.model.PlayerEpisode +import java.time.Duration import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceTimeBy @@ -24,7 +25,6 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test -import java.time.Duration @OptIn(ExperimentalCoroutinesApi::class) class MockEpisodePlayerTest {