From b35241c0a6937dae0dc258da981187f92c73a2ea Mon Sep 17 00:00:00 2001 From: Yang Date: Sat, 24 Feb 2024 18:33:40 +1100 Subject: [PATCH] `SeekBar` progress synchronization with fake playback controller. --- .../KotlinWeeklyIssueScreen.kt | 3 +- .../talking-kotlin-episode/build.gradle.kts | 2 + .../component/PodcastPlayer.kt | 155 ++++++++++++++++-- .../talkingkotlinepisode/component/SeekBar.kt | 108 ++++++------ .../component/PlaybackProgressLabelsTest.kt | 24 +++ .../kstreamlined/buildlogic/KMPBuildLogic.kt | 1 - 6 files changed, 228 insertions(+), 65 deletions(-) create mode 100644 android/feature/talking-kotlin-episode/src/test/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/PlaybackProgressLabelsTest.kt diff --git a/android/feature/kotlin-weekly-issue/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/kotlinweeklyissue/KotlinWeeklyIssueScreen.kt b/android/feature/kotlin-weekly-issue/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/kotlinweeklyissue/KotlinWeeklyIssueScreen.kt index bba1b65e..75e71c2e 100644 --- a/android/feature/kotlin-weekly-issue/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/kotlinweeklyissue/KotlinWeeklyIssueScreen.kt +++ b/android/feature/kotlin-weekly-issue/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/kotlinweeklyissue/KotlinWeeklyIssueScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -66,7 +67,7 @@ public fun KotlinWeeklyIssueScreen( LaunchedEffect(id) { viewModel.loadKotlinWeeklyIssue(id) } - val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val title = stringResource(id = R.string.title_kotlin_weekly_issue, issueNumber) KotlinWeeklyIssueScreen( diff --git a/android/feature/talking-kotlin-episode/build.gradle.kts b/android/feature/talking-kotlin-episode/build.gradle.kts index dfa7d498..5449dc49 100644 --- a/android/feature/talking-kotlin-episode/build.gradle.kts +++ b/android/feature/talking-kotlin-episode/build.gradle.kts @@ -23,4 +23,6 @@ dependencies { // ExoPlayer implementation(libs.androidx.media3.exoplayer) + + testImplementation(kotlin("test")) } diff --git a/android/feature/talking-kotlin-episode/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/PodcastPlayer.kt b/android/feature/talking-kotlin-episode/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/PodcastPlayer.kt index dbf396c5..2107b95e 100644 --- a/android/feature/talking-kotlin-episode/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/PodcastPlayer.kt +++ b/android/feature/talking-kotlin-episode/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/PodcastPlayer.kt @@ -15,10 +15,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -27,6 +28,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import io.github.reactivecircus.kstreamlined.android.foundation.composeutils.marqueeWithFadedEdges import io.github.reactivecircus.kstreamlined.android.foundation.designsystem.component.LargeIconButton @@ -36,6 +38,20 @@ import io.github.reactivecircus.kstreamlined.android.foundation.designsystem.fou import io.github.reactivecircus.kstreamlined.android.foundation.designsystem.foundation.icon.KSIcons import io.github.reactivecircus.kstreamlined.android.foundation.designsystem.foundation.icon.Pause import io.github.reactivecircus.kstreamlined.kmp.presentation.talkingkotlinepisode.TalkingKotlinEpisode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean @Composable internal fun PodcastPlayer( @@ -44,6 +60,118 @@ internal fun PodcastPlayer( onPlayPauseButtonClick: () -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), +) { + @Suppress("MagicNumber") + val initialProgressMillis = 1200_000 + + val scope = rememberCoroutineScope() + val playbackController = remember { FakePlaybackController(scope) } + LaunchedEffect(Unit) { + playbackController.init(initialProgressMillis) + } + DisposableEffect(isPlaying) { + if (isPlaying) { + playbackController.play() + } else { + playbackController.pause() + } + onDispose { } + } + + val playbackState by playbackController.playbackState.collectAsStateWithLifecycle() + + PodcastPlayerUi( + playerProgressMillis = playbackState.progressMillis, + playerDurationMillis = playbackState.durationMillis, + onProgressChange = { progress -> + scope.launch { + playbackController.syncProgress(progress) + } + }, + episode = episode, + isPlaying = isPlaying, + onPlayPauseButtonClick = onPlayPauseButtonClick, + modifier = modifier, + contentPadding = contentPadding, + ) +} + +private data class PlaybackState( + val progressMillis: Int, + val durationMillis: Int, +) + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("MagicNumber") +private class FakePlaybackController(scope: CoroutineScope) { + private val _playbackState = MutableStateFlow(PlaybackState(0, 0)) + val playbackState: StateFlow = _playbackState + + private val _initialized = MutableStateFlow(false) + private val _isPlaying = MutableStateFlow(false) + private val _syncing = AtomicBoolean(false) + + init { + scope.launch { + combine(_initialized, _isPlaying) { initialized, isPlaying -> + initialized to isPlaying + }.distinctUntilChanged().flatMapLatest { (initialized, isPlaying) -> + if (initialized && isPlaying) { + flow { + while (true) { + delay(1000) + emit(Unit) + } + } + } else { + emptyFlow() + } + }.collectLatest { + if (!_syncing.get()) { + _playbackState.update { + it.copy(progressMillis = (it.progressMillis + 1000).coerceAtMost(it.durationMillis)) + } + } + } + } + } + + suspend fun init(initialProgressMillis: Int) { + delay(500) + _playbackState.update { + PlaybackState(initialProgressMillis, 3000_000) + } + _initialized.value = true + } + + suspend fun syncProgress(progressMillis: Int) { + _syncing.set(true) + delay(500) + _playbackState.update { + it.copy(progressMillis = progressMillis) + } + _syncing.set(false) + } + + fun play() { + _isPlaying.value = true + } + + fun pause() { + _isPlaying.value = false + } +} + +@Composable +internal fun PodcastPlayerUi( + playerProgressMillis: Int, + playerDurationMillis: Int, + onProgressChange: (Int) -> Unit, + episode: TalkingKotlinEpisode, + isPlaying: Boolean, + onPlayPauseButtonClick: () -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), ) { Surface( modifier = modifier.fillMaxWidth(), @@ -94,15 +222,10 @@ internal fun PodcastPlayer( style = KSTheme.typography.bodySmall, ) - @Suppress("MagicNumber") - var progressMillis by remember { mutableLongStateOf(1200_000L) } - SeekBar( - progressMillis = progressMillis, - durationMillis = 3000_000L, - onProgressChangeFinished = { - progressMillis = it - }, + progressMillis = playerProgressMillis, + durationMillis = playerDurationMillis, + onProgressChangeFinished = onProgressChange, modifier = Modifier.fillMaxWidth(), ) } @@ -126,10 +249,13 @@ internal fun PodcastPlayer( @Composable @PreviewLightDark -private fun PreviewPodcastPlayer_paused() { +private fun PreviewPodcastPlayerUi_paused() { KSTheme { Surface { - PodcastPlayer( + PodcastPlayerUi( + playerProgressMillis = 1200_000, + playerDurationMillis = 3000_000, + onProgressChange = {}, episode = TalkingKotlinEpisode( id = "1", title = "Talking Kotlin Episode Title", @@ -154,7 +280,10 @@ private fun PreviewPodcastPlayer_paused() { private fun PreviewPodcastPlayer_playing() { KSTheme { Surface { - PodcastPlayer( + PodcastPlayerUi( + playerProgressMillis = 1200_000, + playerDurationMillis = 3000_000, + onProgressChange = {}, episode = TalkingKotlinEpisode( id = "1", title = "Talking Kotlin Episode Title", diff --git a/android/feature/talking-kotlin-episode/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/SeekBar.kt b/android/feature/talking-kotlin-episode/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/SeekBar.kt index 0478ae70..b84595a8 100644 --- a/android/feature/talking-kotlin-episode/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/SeekBar.kt +++ b/android/feature/talking-kotlin-episode/src/main/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/SeekBar.kt @@ -16,11 +16,10 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -40,9 +39,9 @@ import kotlin.math.roundToInt @Composable internal fun SeekBar( - progressMillis: Long, - durationMillis: Long, - onProgressChangeFinished: (Long) -> Unit, + progressMillis: Int, + durationMillis: Int, + onProgressChangeFinished: (Int) -> Unit, modifier: Modifier = Modifier, ) { var seeking by remember { mutableStateOf(false) } @@ -65,44 +64,53 @@ internal fun SeekBar( ) val timeLabelsOffsetPx = with(LocalDensity.current) { timeLabelsOffset.toPx() } - var currentProgressMillis by remember { mutableLongStateOf(progressMillis) } + var currentProgressMillis by remember { mutableIntStateOf(progressMillis) } - var fullTrackWidth by remember { mutableIntStateOf(0) } + var fullTrackWidthPx by remember { mutableIntStateOf(0) } var activeTrackWidthPx by remember { mutableFloatStateOf(0f) } - DisposableEffect(fullTrackWidth) { - activeTrackWidthPx = (progressMillis.toFloat() / durationMillis.toFloat()) * fullTrackWidth - onDispose { } + + LaunchedEffect(progressMillis, durationMillis, fullTrackWidthPx) { + if (!seeking && durationMillis > 0) { + currentProgressMillis = progressMillis + activeTrackWidthPx = progressMillis.toFloat() / durationMillis.toFloat() * fullTrackWidthPx + } } Box( modifier = modifier .height(24.dp) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - seeking = true - }, - onTap = { - seeking = false - } - ) - } - .pointerInput(Unit) { - detectDragGestures( - onDragEnd = { - seeking = false - onProgressChangeFinished(currentProgressMillis) - }, - onDragCancel = { - seeking = false - }, - ) { _, dragAmount -> - activeTrackWidthPx = - (activeTrackWidthPx + dragAmount.x).coerceIn(0f, size.width.toFloat()) - currentProgressMillis = - ((activeTrackWidthPx / size.width) * durationMillis).toLong() + .then( + if (durationMillis > 0) { + Modifier + .pointerInput(Unit) { + detectTapGestures( + onPress = { + seeking = true + }, + onTap = { + seeking = false + } + ) + } + .pointerInput(Unit) { + detectDragGestures( + onDragEnd = { + seeking = false + onProgressChangeFinished(currentProgressMillis) + }, + onDragCancel = { + seeking = false + }, + ) { _, dragAmount -> + activeTrackWidthPx = (activeTrackWidthPx + dragAmount.x) + .coerceIn(0f, size.width.toFloat()) + currentProgressMillis = (activeTrackWidthPx / size.width * durationMillis).toInt() + } + } + } else { + Modifier } - }, + ), ) { Layout( { @@ -133,7 +141,7 @@ internal fun SeekBar( .clip(CircleShape) ) { measurables, constraints -> val trackPlaceable = measurables.first().measure(constraints) - fullTrackWidth = trackPlaceable.width + fullTrackWidthPx = trackPlaceable.width layout(trackPlaceable.width, trackPlaceable.height) { trackPlaceable.placeRelative(0, 0) } @@ -144,7 +152,7 @@ internal fun SeekBar( IntOffset(0, timeLabelsOffsetPx.roundToInt()) } ) { - val (playedProgress, remainingProgress) = formatPlaybackTime(currentProgressMillis, durationMillis) + val (playedProgress, remainingProgress) = playbackProgressLabels(currentProgressMillis, durationMillis) Text( text = playedProgress, style = KSTheme.typography.labelSmall, @@ -163,14 +171,17 @@ internal fun SeekBar( } @Suppress("MagicNumber") -private fun formatPlaybackTime(progressMillis: Long, durationMillis: Long): Pair { - val progressSeconds = progressMillis / 1000 - val progressString = String.format( +internal fun playbackProgressLabels(progressMillis: Int, durationMillis: Int): Pair { + if (progressMillis < 0 || durationMillis <= 0 || progressMillis > durationMillis) { + return "00:00:00" to "-00:00:00" + } + val playedSeconds = progressMillis / 1000 + val playedString = String.format( Locale.ENGLISH, "%02d:%02d:%02d", - progressSeconds / 3600, - (progressSeconds % 3600) / 60, - progressSeconds % 60, + playedSeconds / 3600, + (playedSeconds % 3600) / 60, + playedSeconds % 60, ) val remainingSeconds = durationMillis / 1000 - progressMillis / 1000 @@ -181,8 +192,7 @@ private fun formatPlaybackTime(progressMillis: Long, durationMillis: Long): Pair (remainingSeconds % 3600) / 60, remainingSeconds % 60, ) - - return progressString to remainingString + return playedString to remainingString } private const val AnimationDurationMillis = 400 @@ -194,13 +204,11 @@ private fun PreviewSeekBar() { Surface( color = KSTheme.colorScheme.tertiary ) { - @Suppress("MagicNumber") - var progressMillis by remember { mutableLongStateOf(1200_000L) } SeekBar( modifier = Modifier.padding(8.dp), - progressMillis = progressMillis, - durationMillis = 3000_000L, - onProgressChangeFinished = { progressMillis = it }, + progressMillis = 1200_000, + durationMillis = 3000_000, + onProgressChangeFinished = {}, ) } } diff --git a/android/feature/talking-kotlin-episode/src/test/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/PlaybackProgressLabelsTest.kt b/android/feature/talking-kotlin-episode/src/test/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/PlaybackProgressLabelsTest.kt new file mode 100644 index 00000000..6bb3b50c --- /dev/null +++ b/android/feature/talking-kotlin-episode/src/test/java/io/github/reactivecircus/kstreamlined/android/feature/talkingkotlinepisode/component/PlaybackProgressLabelsTest.kt @@ -0,0 +1,24 @@ +package io.github.reactivecircus.kstreamlined.android.feature.talkingkotlinepisode.component + +import org.junit.Test +import kotlin.test.assertEquals + +class PlaybackProgressLabelsTest { + + @Test + fun `test playback progress labels`() { + assertEquals("00:00:00" to "-00:00:00", playbackProgressLabels(0, 0)) + assertEquals("00:00:00" to "-00:00:00", playbackProgressLabels(1000, 0)) + assertEquals("00:00:00" to "-00:00:00", playbackProgressLabels(-1000, 1000)) + assertEquals("00:00:00" to "-00:00:00", playbackProgressLabels(0, -1000)) + assertEquals("00:00:00" to "-00:00:01", playbackProgressLabels(0, 1000)) + assertEquals("00:00:01" to "-00:00:00", playbackProgressLabels(1000, 1000)) + assertEquals("00:00:01" to "-00:00:01", playbackProgressLabels(1000, 2000)) + assertEquals("00:00:59" to "-00:00:01", playbackProgressLabels(59000, 60000)) + assertEquals("00:01:00" to "-00:00:00", playbackProgressLabels(60000, 60000)) + assertEquals("00:01:00" to "-00:00:01", playbackProgressLabels(60000, 61000)) + assertEquals("00:59:59" to "-00:00:01", playbackProgressLabels(3599000, 3600000)) + assertEquals("01:03:24" to "-00:06:36", playbackProgressLabels(3804000, 4200000)) + assertEquals("00:06:36" to "-01:03:24", playbackProgressLabels(396000, 4200000)) + } +} diff --git a/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/KMPBuildLogic.kt b/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/KMPBuildLogic.kt index bfacb772..981991ea 100644 --- a/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/KMPBuildLogic.kt +++ b/build-logic/src/main/kotlin/io/github/reactivecircus/kstreamlined/buildlogic/KMPBuildLogic.kt @@ -44,7 +44,6 @@ internal fun KotlinMultiplatformExtension.configureKMPTest() { jvmTest { dependencies { implementation(kotlin("test")) - implementation(kotlin("test-junit")) } } }