Skip to content

Commit

Permalink
SeekBar progress synchronization with fake playback controller.
Browse files Browse the repository at this point in the history
  • Loading branch information
ychescale9 committed Feb 24, 2024
1 parent 6b99911 commit b35241c
Show file tree
Hide file tree
Showing 6 changed files with 228 additions and 65 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions android/feature/talking-kotlin-episode/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ dependencies {

// ExoPlayer
implementation(libs.androidx.media3.exoplayer)

testImplementation(kotlin("test"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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> = _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(),
Expand Down Expand Up @@ -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(),
)
}
Expand All @@ -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",
Expand All @@ -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",
Expand Down
Loading

0 comments on commit b35241c

Please sign in to comment.