From 475d4dde1bf4e17f91ff03433f615bca77225f7c Mon Sep 17 00:00:00 2001 From: Brian Wernick Date: Thu, 19 Dec 2024 21:48:02 -0700 Subject: [PATCH] Replaced the XML AudioPlayer with a Jetpack Compose based AudioPlayerScreen --- demo/build.gradle | 9 +- .../android/exomediademo/data/MediaItem.kt | 2 +- .../ui/media/AudioPlayerActivity.kt | 297 ++------------ .../ui/media/audio/AudioPlayerScreen.kt | 364 ++++++++++++++++++ .../ui/media/audio/AudioPlayerViewModel.kt | 190 +++++++++ .../ui/selection/SelectionActivity.kt | 19 +- .../ui/selection/SelectionScreen.kt | 116 +++--- .../ui/support/{ => compose}/DemoNavHost.kt | 27 +- .../ui/support/compose/Painter.kt | 70 ++++ .../ui/support/compose/ScreenScaffold.kt | 82 ++++ .../ui/support/compose/seek/Seekbar.kt | 137 +++++++ .../ui/support/compose/theme/Color.kt | 37 ++ .../ui/support/compose/theme/Shape.kt | 6 + .../ui/support/compose/theme/Theme.kt | 51 +++ .../ui/support/compose/theme/Type.kt | 20 + gradle/libs.versions.toml | 11 +- 16 files changed, 1082 insertions(+), 356 deletions(-) create mode 100644 demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/audio/AudioPlayerScreen.kt create mode 100644 demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/audio/AudioPlayerViewModel.kt rename demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/{ => compose}/DemoNavHost.kt (73%) create mode 100644 demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/Painter.kt create mode 100644 demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/ScreenScaffold.kt create mode 100644 demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/seek/Seekbar.kt create mode 100644 demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Color.kt create mode 100644 demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Shape.kt create mode 100644 demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Theme.kt create mode 100644 demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Type.kt diff --git a/demo/build.gradle b/demo/build.gradle index b69b0e4e..3e78b081 100644 --- a/demo/build.gradle +++ b/demo/build.gradle @@ -23,9 +23,9 @@ dependencies { implementation libs.ui implementation libs.ui.tooling implementation libs.ui.tooling.preview - implementation libs.material - implementation libs.material.icons.core - implementation libs.material.icons.extended + implementation libs.compose.material3 + implementation libs.compose.icons.core + implementation libs.compose.icons.extended implementation libs.activity.compose implementation libs.navigation.compose @@ -33,6 +33,9 @@ dependencies { // Image Loading implementation libs.glide annotationProcessor libs.compiler + implementation libs.coil.compose + implementation libs.coil.network + // Playlist support implementation libs.playlistcore diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/data/MediaItem.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/data/MediaItem.kt index 11830543..4bf7f899 100644 --- a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/data/MediaItem.kt +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/data/MediaItem.kt @@ -36,7 +36,7 @@ class MediaItem(private val sample: Samples.Sample, internal var isAudio: Boolea get() = sample.title override val album: String? - get() = "PlaylistCore Demo" + get() = "The Count of Monte Cristo" override val artist: String? get() = "Unknown Artist" diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/AudioPlayerActivity.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/AudioPlayerActivity.kt index 166174c6..bae8e023 100644 --- a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/AudioPlayerActivity.kt +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/AudioPlayerActivity.kt @@ -3,23 +3,12 @@ package com.devbrackets.android.exomediademo.ui.media import android.content.Context import android.content.Intent import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.widget.SeekBar -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestManager -import com.devbrackets.android.exomedia.util.millisToFormattedDuration -import com.devbrackets.android.exomediademo.App -import com.devbrackets.android.exomediademo.R -import com.devbrackets.android.exomediademo.data.MediaItem +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity import com.devbrackets.android.exomediademo.data.Samples -import com.devbrackets.android.exomediademo.databinding.AudioPlayerActivityBinding -import com.devbrackets.android.exomediademo.playlist.manager.PlaylistManager -import com.devbrackets.android.exomediademo.ui.support.BindingActivity -import com.devbrackets.android.playlistcore.data.MediaProgress -import com.devbrackets.android.playlistcore.data.PlaybackState -import com.devbrackets.android.playlistcore.listener.PlaylistListener -import com.devbrackets.android.playlistcore.listener.ProgressListener +import com.devbrackets.android.exomediademo.ui.media.audio.AudioPlayerScreen +import com.devbrackets.android.exomediademo.ui.media.audio.AudioPlayerViewModel /** * An example activity to show how to implement and audio UI @@ -27,259 +16,43 @@ import com.devbrackets.android.playlistcore.listener.ProgressListener * and [com.devbrackets.android.playlistcore.manager.ListPlaylistManager] * classes. */ -class AudioPlayerActivity : BindingActivity(), PlaylistListener, ProgressListener { - companion object { - const val EXTRA_INDEX = "EXTRA_INDEX" - const val PLAYLIST_ID = 4 //Arbitrary, for the example +class AudioPlayerActivity : AppCompatActivity() { + companion object { + const val EXTRA_INDEX = "EXTRA_INDEX" + const val PLAYLIST_ID = 4 //Arbitrary, for the example - fun intent(context: Context, sample: Samples.Sample): Intent { - // NOTE: - // We pass the index of the sample for simplicity, however you will likely - // want to pass an ID for both the selected playlist (audio/video in this demo) - // and the selected media item - val index = Samples.audio.indexOf(sample) + fun intent(context: Context, sample: Samples.Sample): Intent { + // NOTE: + // We pass the index of the sample for simplicity, however you will likely + // want to pass an ID for both the selected playlist (audio/video in this demo) + // and the selected media item + val index = Samples.audio.indexOf(sample) - return Intent(context, AudioPlayerActivity::class.java).apply { - putExtra(EXTRA_INDEX, index) - } - } + return Intent(context, AudioPlayerActivity::class.java).apply { + putExtra(EXTRA_INDEX, index) + } } + } - private var shouldSetDuration: Boolean = false - private var userInteracting: Boolean = false + private val viewModel: AudioPlayerViewModel by viewModels { + AudioPlayerViewModel.factory(applicationContext) + } - private lateinit var playlistManager: PlaylistManager - private val selectedPosition by lazy { intent.extras?.getInt(EXTRA_INDEX, 0) ?: 0 } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - private val glide: RequestManager by lazy { Glide.with(this) } - - override fun inflateBinding(layoutInflater: LayoutInflater): AudioPlayerActivityBinding { - return AudioPlayerActivityBinding.inflate(layoutInflater) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - init() - } - - override fun onPause() { - super.onPause() - playlistManager.unRegisterPlaylistListener(this) - playlistManager.unRegisterProgressListener(this) - } - - override fun onResume() { - super.onResume() - playlistManager = (applicationContext as App).playlistManager - playlistManager.registerPlaylistListener(this) - playlistManager.registerProgressListener(this) - - //Makes sure to retrieve the current playback information - updateCurrentPlaybackInformation() - } - - override fun onPlaylistItemChanged(currentItem: MediaItem?, hasNext: Boolean, hasPrevious: Boolean): Boolean { - shouldSetDuration = true - - //Updates the button states - binding.nextButton.isEnabled = hasNext - binding.previousButton.isEnabled = hasPrevious - - //Loads the new image - currentItem?.let { - glide.load(it.artworkUrl).into(binding.artworkView) - } - - return true - } - - override fun onPlaybackStateChanged(playbackState: PlaybackState): Boolean { - when (playbackState) { - PlaybackState.STOPPED -> finish() - PlaybackState.RETRIEVING, PlaybackState.PREPARING, PlaybackState.SEEKING -> restartLoading() - PlaybackState.PLAYING -> doneLoading(true) - PlaybackState.PAUSED -> doneLoading(false) - else -> {} - } - - return true - } - - override fun onProgressUpdated(mediaProgress: MediaProgress): Boolean { - if (shouldSetDuration && mediaProgress.duration > 0) { - shouldSetDuration = false - setDuration(mediaProgress.duration) - } - - if (!userInteracting) { - binding.seekBar.secondaryProgress = (mediaProgress.duration * mediaProgress.bufferPercentFloat).toInt() - binding.seekBar.progress = mediaProgress.position.toInt() - binding.currentPositionView.text = mediaProgress.position.millisToFormattedDuration() - } - - return true - } - - /** - * Makes sure to update the UI to the current playback item. - */ - private fun updateCurrentPlaybackInformation() { - playlistManager.currentItemChange?.let { - onPlaylistItemChanged(it.currentItem, it.hasNext, it.hasPrevious) - } - - if (playlistManager.currentPlaybackState != PlaybackState.STOPPED) { - onPlaybackStateChanged(playlistManager.currentPlaybackState) - } - - playlistManager.currentProgress?.let { - onProgressUpdated(it) - } - } - - /** - * Performs the initialization of the views and any other - * general setup - */ - private fun init() { - setupListeners() - startPlayback(setupPlaylistManager()) - } - - - /** - * Called when we receive a notification that the current item is - * done loading. This will then update the view visibilities and - * states accordingly. - * - * @param isPlaying True if the audio item is currently playing - */ - private fun doneLoading(isPlaying: Boolean) { - loadCompleted() - updatePlayPauseImage(isPlaying) - } - - /** - * Updates the Play/Pause image to represent the correct playback state - * - * @param isPlaying True if the audio item is currently playing - */ - private fun updatePlayPauseImage(isPlaying: Boolean) { - val resId = if (isPlaying) R.drawable.playlistcore_ic_pause_black else R.drawable.playlistcore_ic_play_arrow_black - binding.playPauseButton.setImageResource(resId) - } - - /** - * Used to inform the controls to finalize their setup. This - * means replacing the loading animation with the PlayPause button - */ - private fun loadCompleted() { - binding.playPauseButton.visibility = View.VISIBLE - binding.previousButton.visibility = View.VISIBLE - binding.nextButton.visibility = View.VISIBLE - - binding.loadingBar.visibility = View.INVISIBLE - } - - /** - * Used to inform the controls to return to the loading stage. - * This is the opposite of [.loadCompleted] - */ - private fun restartLoading() { - binding.playPauseButton.visibility = View.INVISIBLE - binding.previousButton.visibility = View.INVISIBLE - binding.nextButton.visibility = View.INVISIBLE - - binding.loadingBar.visibility = View.VISIBLE - } - - /** - * Sets the [.seekBar]s max and updates the duration text - * - * @param duration The duration of the media item in milliseconds - */ - private fun setDuration(duration: Long) { - binding.seekBar.max = duration.toInt() - binding.durationView.text = duration.millisToFormattedDuration() - } - - /** - * Retrieves the playlist instance and performs any generation - * of content if it hasn't already been performed. - * - * @return True if the content was generated - */ - private fun setupPlaylistManager(): Boolean { - playlistManager = (applicationContext as App).playlistManager - - //There is nothing to do if the currently playing values are the same - if (playlistManager.id == PLAYLIST_ID.toLong()) { - return false - } - - val mediaItems = Samples.audio.map { - MediaItem(it, true) - } - - playlistManager.setParameters(mediaItems, selectedPosition) - playlistManager.id = PLAYLIST_ID.toLong() - - return true - } - - /** - * Links the SeekBarChanged to the [.seekBar] and - * onClickListeners to the media buttons that call the appropriate - * invoke methods in the [.playlistManager] - */ - private fun setupListeners() { - binding.seekBar.setOnSeekBarChangeListener(SeekBarChanged()) - binding.previousButton.setOnClickListener { playlistManager.invokePrevious() } - binding.playPauseButton.setOnClickListener { playlistManager.invokePausePlay() } - binding.nextButton.setOnClickListener { playlistManager.invokeNext() } - } - - /** - * Starts the audio playback if necessary. - * - * @param forceStart True if the audio should be started from the beginning even if it is currently playing - */ - private fun startPlayback(forceStart: Boolean) { - //If we are changing audio files, or we haven't played before then start the playback - if (forceStart || playlistManager.currentPosition != selectedPosition) { - playlistManager.currentPosition = selectedPosition - playlistManager.play(0, false) - } + setContent { + AudioPlayerScreen( + viewModel = viewModel, + onBackClicked = ::onBackPressed + ) } - /** - * Listens to the seek bar change events and correctly handles the changes - */ - private inner class SeekBarChanged : SeekBar.OnSeekBarChangeListener { - private var seekPosition = -1 - - override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - if (!fromUser) { - return - } - - seekPosition = progress - binding.currentPositionView.text = progress.toLong().millisToFormattedDuration() - } - - override fun onStartTrackingTouch(seekBar: SeekBar) { - userInteracting = true + viewModel.startPlayback() + } - seekPosition = seekBar.progress - playlistManager.invokeSeekStarted() - } - - override fun onStopTrackingTouch(seekBar: SeekBar) { - userInteracting = false - - playlistManager.invokeSeekEnded(seekPosition.toLong()) - seekPosition = -1 - } - } + override fun onResume() { + super.onResume() + viewModel.connectPlaylist() + } } diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/audio/AudioPlayerScreen.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/audio/AudioPlayerScreen.kt new file mode 100644 index 00000000..5d3c101a --- /dev/null +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/audio/AudioPlayerScreen.kt @@ -0,0 +1,364 @@ +package com.devbrackets.android.exomediademo.ui.media.audio + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.SkipNext +import androidx.compose.material.icons.rounded.SkipPrevious +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.devbrackets.android.exomedia.util.millisToFormattedDuration +import com.devbrackets.android.exomediademo.ui.support.compose.ScreenScaffold +import com.devbrackets.android.exomediademo.ui.support.compose.forwardingPainter +import com.devbrackets.android.exomediademo.ui.support.compose.seek.SeekState +import com.devbrackets.android.exomediademo.ui.support.compose.seek.Seekbar +import com.devbrackets.android.exomediademo.ui.support.compose.theme.DemoTheme +import com.devbrackets.android.playlistcore.data.PlaybackState + +@Composable +internal fun AudioPlayerScreen( + viewModel: AudioPlayerViewModel, + onBackClicked: () -> Unit, +) { + val playbackState = viewModel.playbackState.collectAsState(null) + val playbackItem = viewModel.playbackItem.collectAsState(null) + val playbackPosition = viewModel.playbackPosition.collectAsState(null) + val playbackDuration = viewModel.playbackDuration.collectAsState(null) + + AudioPlayerScreen( + playbackState = playbackState, + playbackItem = playbackItem, + playbackPosition = playbackPosition, + playbackDuration = playbackDuration, + onBackClicked = onBackClicked, + onPlayPause = viewModel::playPause, + onSeekPrevious = viewModel::seekPrevious, + onSeekNext = viewModel::seekNext, + onSeek = viewModel::seek + ) +} + +@Composable +private fun AudioPlayerScreen( + playbackState: State, + playbackItem: State, + playbackPosition: State, + playbackDuration: State, + onBackClicked: () -> Unit, + onPlayPause: () -> Unit, + onSeekPrevious: () -> Unit, + onSeekNext: () -> Unit, + onSeek: (SeekState) -> Unit, +) { + val seekPosition = remember { mutableStateOf(null) } + + ScreenScaffold( + title = playbackItem.value?.title.orEmpty(), + subTitle = playbackItem.value?.album, + onBackClick = onBackClicked + ) { + Column( + modifier = Modifier + .fillMaxSize() + .playerBackground(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly + ) { + Artwork( + playbackItem = playbackItem, + modifier = Modifier + .padding(horizontal = 56.dp) + .size(256.dp) + .fillMaxSize() + .aspectRatio(1f) + ) + + PlaybackControls( + playbackState = playbackState, + playbackItem = playbackItem, + playbackPosition = playbackPosition, + playbackDuration = playbackDuration, + seekPosition = seekPosition, + onPlayPause = onPlayPause, + onSeekPrevious = onSeekPrevious, + onSeekNext = onSeekNext, + onSeek = onSeek, + modifier = Modifier + .padding(horizontal = 24.dp) + .widthIn(max = 540.dp) + .fillMaxWidth() + ) + } + } +} + +@Composable +private fun PlaybackControls( + playbackState: State, + playbackItem: State, + playbackPosition: State, + playbackDuration: State, + seekPosition: MutableState, + onPlayPause: () -> Unit, + onSeekPrevious: () -> Unit, + onSeekNext: () -> Unit, + onSeek: (SeekState) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + PlaybackPosition( + playbackPosition = playbackPosition, + playbackDuration = playbackDuration, + seekPosition = seekPosition, + modifier = Modifier.align(Alignment.End) + ) + + Seekbar( + position = playbackPosition, + maxValue = playbackDuration, + enabled = when (playbackState.value) { + PlaybackState.PLAYING, PlaybackState.PAUSED, PlaybackState.SEEKING -> true + else -> false + }, + onSeek = { + onSeek(it) + when (it) { + is SeekState.Started -> seekPosition.value = it.position + is SeekState.Seeking -> seekPosition.value = it.position + is SeekState.Finished -> seekPosition.value = null + } + }, + modifier = Modifier.fillMaxWidth(), + ) + + PlaybackActions( + playbackState = playbackState, + playbackItem = playbackItem, + onPlayPause = onPlayPause, + onSeekPrevious = onSeekPrevious, + onSeekNext = onSeekNext, + modifier = Modifier + .padding(top = 24.dp) + .fillMaxWidth() + ) + } +} + +@Composable +private fun PlaybackActions( + playbackState: State, + playbackItem: State, + onPlayPause: () -> Unit, + onSeekPrevious: () -> Unit, + onSeekNext: () -> Unit, + modifier: Modifier = Modifier +) { + val playPauseVector = remember(playbackState.value) { + when (playbackState.value) { + PlaybackState.PLAYING -> Icons.Rounded.Pause + else -> Icons.Rounded.PlayArrow + } + } + + val playPauseEnabled = remember(playbackState.value) { + when (playbackState.value) { + PlaybackState.PLAYING, PlaybackState.PAUSED -> true + else -> false + } + } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(24.dp, alignment = Alignment.CenterHorizontally) + ) { + IconButton( + onClick = onSeekPrevious, + enabled = playbackItem.value?.hasPrevious ?: false, + ) { + Icon( + imageVector = Icons.Rounded.SkipPrevious, + contentDescription = "Skip to Previous", // Should be a string resource + modifier = Modifier.size(48.dp) + ) + } + + IconButton( + onClick = onPlayPause, + enabled = playPauseEnabled + ) { + Icon( + imageVector = playPauseVector, + contentDescription = "Play or Pause", // Should be a string resource + modifier = Modifier.size(48.dp) + ) + } + + IconButton( + onClick = onSeekNext, + enabled = playbackItem.value?.hasNext ?: false, + ) { + Icon( + imageVector = Icons.Rounded.SkipNext, + contentDescription = "Skip to Next", // Should be a string resource + modifier = Modifier.size(48.dp) + ) + } + } +} + +@Composable +private fun PlaybackPosition( + playbackPosition: State, + playbackDuration: State, + seekPosition: State, + modifier: Modifier = Modifier +) { + val positionText = remember { + derivedStateOf { + seekPosition.value?.millisToFormattedDuration() ?: playbackPosition.value?.millisToFormattedDuration() + } + } + + val durationText = remember(playbackDuration.value) { + playbackDuration.value?.millisToFormattedDuration() + } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.End) + ) { + Text( + text = positionText.value ?: "--" + ) + + Text( + text = "/" + ) + + Text( + text = durationText ?: "--" + ) + } +} + +@Composable +private fun Artwork( + playbackItem: State, + modifier: Modifier = Modifier +) { + val artworkUrl = remember { + derivedStateOf { + playbackItem.value?.artworkUrl + } + } + + val placeholder = forwardingPainter( + painter = rememberVectorPainter(Icons.Rounded.MusicNote), + colorFilter = ColorFilter.tint(LocalContentColor.current), + alpha = 0.2f + ) + + Surface( + modifier = modifier, + shape = DemoTheme.shapes.extraLarge, + tonalElevation = 16.dp, + shadowElevation = 16.dp + ) { + AsyncImage( + model = artworkUrl.value, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + placeholder = placeholder, + error = placeholder, + fallback = placeholder, + contentScale = ContentScale.Crop + ) + } +} + +@Composable +private fun Modifier.playerBackground(): Modifier { + val colorScheme = DemoTheme.colors + + // Manually drawing instead of using `background()` so that we can + // adjust positioning + return this.drawWithCache { + val brush = Brush.radialGradient( + colorStops = arrayOf( + 0f to colorScheme.secondary.copy(alpha = 0.6f), + 0.5f to colorScheme.secondary.copy(alpha = 0.6f), + 1f to colorScheme.secondary.copy(alpha = 0.25f) + ), + center = size.center.copy(y = size.height * 0.3f) + ) + + onDrawWithContent { + drawRect(brush = brush) + + drawContent() + } + } +} + +@Composable +@Preview(showBackground = true) +private fun PreviewAudioPlayerScreen() { + val playbackState = remember { mutableStateOf(PlaybackState.PLAYING) } + val playbackItem = remember { + mutableStateOf( + AudioPlayerViewModel.PlaybackItem( + title = "Conspiracy", + album = "The Count of Monte Cristo", + artworkUrl = null, + hasNext = true, + hasPrevious = true + ) + ) + } + + AudioPlayerScreen( + playbackState = playbackState, + playbackItem = playbackItem, + playbackPosition = remember { mutableStateOf(254_000) }, + playbackDuration = remember { mutableStateOf(490_000) }, + onBackClicked = {}, + onPlayPause = {}, + onSeekPrevious = {}, + onSeekNext = {}, + onSeek = {} + ) +} \ No newline at end of file diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/audio/AudioPlayerViewModel.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/audio/AudioPlayerViewModel.kt new file mode 100644 index 00000000..d1a592da --- /dev/null +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/media/audio/AudioPlayerViewModel.kt @@ -0,0 +1,190 @@ +package com.devbrackets.android.exomediademo.ui.media.audio + +import android.annotation.SuppressLint +import android.app.Application +import android.content.Context +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.devbrackets.android.exomediademo.App +import com.devbrackets.android.exomediademo.data.MediaItem +import com.devbrackets.android.exomediademo.data.Samples +import com.devbrackets.android.exomediademo.playlist.manager.PlaylistManager +import com.devbrackets.android.exomediademo.ui.media.AudioPlayerActivity +import com.devbrackets.android.exomediademo.ui.support.compose.seek.SeekState +import com.devbrackets.android.playlistcore.data.MediaProgress +import com.devbrackets.android.playlistcore.data.PlaybackState +import com.devbrackets.android.playlistcore.listener.PlaylistListener +import com.devbrackets.android.playlistcore.listener.ProgressListener +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +internal class AudioPlayerViewModel( + private val savedStateHandle: SavedStateHandle, + val playlistManager: PlaylistManager +) : ViewModel(), PlaylistListener, ProgressListener { + companion object { + fun factory(context: Context): ViewModelProvider.Factory { + return Factory(context.applicationContext as Application) + } + } + + private val selectedPosition by lazy { + savedStateHandle.get(AudioPlayerActivity.EXTRA_INDEX) ?: 0 + } + + private val mutablePlaybackState = MutableStateFlow(null) + val playbackState: Flow = mutablePlaybackState + + private val mutablePlaybackItem = MutableStateFlow(null) + val playbackItem: Flow = mutablePlaybackItem + + // PlaylistCore just modifies a single object so we use a SharedFlow instead + // of a StateFlow + private val mutableMediaProgress = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val playbackPosition: Flow = mutableMediaProgress.map { it?.position } + val playbackDuration: Flow = mutableMediaProgress.map { it?.duration } + + override fun onPlaybackStateChanged(playbackState: PlaybackState): Boolean { + mutablePlaybackState.value = playbackState + return true + } + + override fun onPlaylistItemChanged(currentItem: MediaItem?, hasNext: Boolean, hasPrevious: Boolean): Boolean { + val item = PlaybackItem( + title = currentItem?.title, + album = currentItem?.album, + artworkUrl = currentItem?.artworkUrl, + hasNext = hasNext, + hasPrevious = hasPrevious + ) + + mutablePlaybackItem.value = item + mutableMediaProgress.tryEmit(null) + + return true + } + + override fun onProgressUpdated(mediaProgress: MediaProgress): Boolean { + mutableMediaProgress.tryEmit(mediaProgress) + return true + } + + override fun onCleared() { + super.onCleared() + + playlistManager.unRegisterPlaylistListener(this) + playlistManager.unRegisterProgressListener(this) + } + + fun connectPlaylist() { + playlistManager.registerPlaylistListener(this) + playlistManager.registerProgressListener(this) + + // Makes sure to retrieve the current playback information + updateCurrentPlaybackInformation() + } + + fun playPause() { + playlistManager.invokePausePlay() + } + + fun seekPrevious() { + playlistManager.invokePrevious() + } + + fun seekNext() { + playlistManager.invokeNext() + } + + fun seek(state: SeekState) { + when (state) { + is SeekState.Started -> playlistManager.invokeSeekStarted() + is SeekState.Seeking -> { /* No Op */ } + is SeekState.Finished -> playlistManager.invokeSeekEnded(state.position) + } + } + + private fun updateCurrentPlaybackInformation() { + playlistManager.currentItemChange?.let { + onPlaylistItemChanged(it.currentItem, it.hasNext, it.hasPrevious) + } + + if (playlistManager.currentPlaybackState != PlaybackState.STOPPED) { + onPlaybackStateChanged(playlistManager.currentPlaybackState) + } + + playlistManager.currentProgress?.let { + onProgressUpdated(it) + } + } + + /** + * Starts the audio playback if necessary. + */ + @SuppressLint("Range") + fun startPlayback() { + val forceStart = setupPlaylistManager() + + //If we are changing audio files, or we haven't played before then start the playback + if (forceStart || playlistManager.currentPosition != selectedPosition) { + playlistManager.currentPosition = selectedPosition + playlistManager.play(0, false) + } + } + + /** + * Retrieves the playlist instance and performs any generation + * of content if it hasn't already been performed. + * + * @return True if the content was generated + */ + @SuppressLint("Range") + private fun setupPlaylistManager(): Boolean { + // There is nothing to do if the currently playing values are the same + if (playlistManager.id == AudioPlayerActivity.PLAYLIST_ID.toLong()) { + return false + } + + val mediaItems = Samples.audio.map { + MediaItem(it, true) + } + + playlistManager.setParameters(mediaItems, selectedPosition) + playlistManager.id = AudioPlayerActivity.PLAYLIST_ID.toLong() + + return true + } + + data class PlaybackItem( + val title: String?, + val album: String?, + val artworkUrl: String?, + val hasNext: Boolean, + val hasPrevious: Boolean + ) + + private class Factory( + private val application: Application + ) : AbstractSavedStateViewModelFactory() { + @Suppress("UNCHECKED_CAST") + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle + ): T { + return AudioPlayerViewModel( + savedStateHandle = handle, + playlistManager = (application as App).playlistManager + ) as T + } + } +} \ No newline at end of file diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/selection/SelectionActivity.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/selection/SelectionActivity.kt index 64288db0..b5ba26c5 100644 --- a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/selection/SelectionActivity.kt +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/selection/SelectionActivity.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.background import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.navigation.NavType import androidx.navigation.compose.composable @@ -18,7 +17,8 @@ import com.devbrackets.android.exomediademo.data.Samples import com.devbrackets.android.exomediademo.data.Samples.Sample.Category import com.devbrackets.android.exomediademo.ui.media.AudioPlayerActivity import com.devbrackets.android.exomediademo.ui.media.VideoPlayerActivity -import com.devbrackets.android.exomediademo.ui.support.DemoNavHost +import com.devbrackets.android.exomediademo.ui.support.compose.DemoNavHost +import com.devbrackets.android.exomediademo.ui.support.compose.theme.DemoTheme import com.devbrackets.android.exomediademo.util.getEnumArg @ExperimentalAnimationApi @@ -37,7 +37,7 @@ class SelectionActivity : AppCompatActivity() { DemoNavHost( navController = navController, startDestination = "home", - modifier = Modifier.background(Color(235, 235, 245)), + modifier = Modifier.background(DemoTheme.colors.background), ) { composable( route = "home" @@ -58,7 +58,12 @@ class SelectionActivity : AppCompatActivity() { } ) ) { entry -> - SelectMedia(entry.getEnumArg("category")) + SelectMedia( + category = entry.getEnumArg("category"), + onBackClicked = { + navController.popBackStack() + } + ) } } @@ -79,7 +84,10 @@ class SelectionActivity : AppCompatActivity() { } @Composable - private fun SelectMedia(category: Category) { + private fun SelectMedia( + category: Category, + onBackClicked: () -> Unit, + ) { val title = when(category) { Category.AUDIO -> stringResource(R.string.title_audio_selection_activity) Category.VIDEO -> stringResource(R.string.title_video_selection_activity) @@ -93,6 +101,7 @@ class SelectionActivity : AppCompatActivity() { SelectionScreen( title = title, samples = samples, + onBackClicked = onBackClicked, onSampleSelected = this::playMedia ) } diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/selection/SelectionScreen.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/selection/SelectionScreen.kt index 171003ce..ca7b2b4e 100644 --- a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/selection/SelectionScreen.kt +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/selection/SelectionScreen.kt @@ -1,6 +1,7 @@ package com.devbrackets.android.exomediademo.ui.selection import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.defaultMinSize @@ -12,67 +13,43 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.items -import androidx.compose.material.Card -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Audiotrack import androidx.compose.material.icons.rounded.Videocam +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.devbrackets.android.exomediademo.R import com.devbrackets.android.exomediademo.data.Samples - -@Composable -fun SelectionScreenFrame( - title: String, - content: @Composable (PaddingValues) -> Unit -) { - MaterialTheme( - colors = MaterialTheme.colors.copy( - primary = Color(66, 165, 245), - primaryVariant = Color(96, 125, 139), - secondary = Color(96, 125, 139), - background = Color(235, 235, 245) - ) - ) { - Scaffold( - topBar = { - TopAppBar( - title = { - Text(title) - }, - backgroundColor = MaterialTheme.colors.primaryVariant - ) - }, - content = content - ) - } -} +import com.devbrackets.android.exomediademo.ui.support.compose.ScreenScaffold +import com.devbrackets.android.exomediademo.ui.support.compose.theme.DemoTheme @Composable fun CategorySelectionScreen( onAudioSelected: () -> Unit, onVideoSelected: () -> Unit ) { - SelectionScreenFrame( + ScreenScaffold( title = stringResource(R.string.app_name) - ) { + ) { padding -> LazyVerticalGrid( columns = GridCells.Fixed(2), - contentPadding = PaddingValues(16.dp) + modifier = Modifier.padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp, alignment = Alignment.CenterHorizontally) ) { item { MediaCategoryCard( @@ -97,12 +74,15 @@ fun CategorySelectionScreen( fun SelectionScreen( title: String, samples: List, + onBackClicked: () -> Unit, onSampleSelected: (Samples.Sample) -> Unit ) { - SelectionScreenFrame( - title = title - ) { + ScreenScaffold( + title = title, + onBackClick = onBackClicked + ) { padding -> LazyColumn( + modifier = Modifier.padding(padding), contentPadding = PaddingValues( top = 8.dp, bottom = 56.dp @@ -144,37 +124,34 @@ fun MediaCategoryCard( image: ImageVector, onClick: () -> Unit ) { - Box { - Card( + Card( + onClick = onClick, + modifier = Modifier.size(124.dp), + elevation = CardDefaults.elevatedCardElevation() + ) { + Box( modifier = Modifier - .size(136.dp, 136.dp) - .clip(MaterialTheme.shapes.medium) - .clickable(onClick = onClick) - .align(Alignment.Center), - elevation = 2.dp + .fillMaxSize() + .padding(16.dp) ) { - Box( + Icon( + imageVector = image, + contentDescription = null, modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - Icon( - imageVector = image, - contentDescription = null, - modifier = Modifier - .padding(bottom = 24.dp) - .size(48.dp) - .align(Alignment.Center), - tint = Color(235, 235, 245) - ) + .padding(bottom = 24.dp) + .size(48.dp) + .align(Alignment.Center), + tint = LocalContentColor.current.copy(alpha = 0.4f) + ) - Text( - text = title, - modifier = Modifier.align(Alignment.BottomStart), - fontSize = 24.sp, - fontWeight = FontWeight.Medium - ) - } + Text( + text = title, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + textAlign = TextAlign.Center, + style = DemoTheme.typography.titleLarge + ) } } } @@ -185,6 +162,7 @@ private fun PreviewSelectionScreen() { SelectionScreen( title = "Select a video", samples = Samples.video, + onBackClicked = {}, onSampleSelected = {} ) } \ No newline at end of file diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/DemoNavHost.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/DemoNavHost.kt similarity index 73% rename from demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/DemoNavHost.kt rename to demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/DemoNavHost.kt index 9e8a2691..43cabcad 100644 --- a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/DemoNavHost.kt +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/DemoNavHost.kt @@ -1,4 +1,4 @@ -package com.devbrackets.android.exomediademo.ui.support +package com.devbrackets.android.exomediademo.ui.support.compose import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween @@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost +import com.devbrackets.android.exomediademo.ui.support.compose.theme.DemoTheme private const val ANIMATION_DURATION = 300 @@ -52,15 +53,17 @@ fun DemoNavHost( route: String? = null, builder: NavGraphBuilder.() -> Unit ) { - NavHost( - navController = navController, - startDestination = startDestination, - modifier = modifier, - route = route, - enterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popEnterTransition = { popEnterTransition }, - popExitTransition = { popExitTransition }, - builder = builder - ) + DemoTheme { + NavHost( + navController = navController, + startDestination = startDestination, + modifier = modifier, + route = route, + enterTransition = { enterTransition }, + exitTransition = { exitTransition }, + popEnterTransition = { popEnterTransition }, + popExitTransition = { popExitTransition }, + builder = builder + ) + } } \ No newline at end of file diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/Painter.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/Painter.kt new file mode 100644 index 00000000..a332d4f6 --- /dev/null +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/Painter.kt @@ -0,0 +1,70 @@ +package com.devbrackets.android.exomediademo.ui.support.compose + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.DefaultAlpha +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.painter.Painter + +/** + * Coil3 forwarding painter + * https://gist.github.com/colinrtwhite/c2966e0b8584b4cdf0a5b05786b20ae1 + * + * Create and return a new [Painter] that wraps [painter] + * with its [alpha], [colorFilter], or [onDraw] overwritten. + */ +@Stable +fun forwardingPainter( + painter: Painter, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + onDraw: DrawScope.(ForwardingDrawInfo) -> Unit = DefaultOnDraw, +): Painter = ForwardingPainter(painter, alpha, colorFilter, onDraw) + +data class ForwardingDrawInfo( + val painter: Painter, + val alpha: Float, + val colorFilter: ColorFilter?, +) + +/** + * Coil3 forwarding painter + * https://gist.github.com/colinrtwhite/c2966e0b8584b4cdf0a5b05786b20ae1 + */ +private class ForwardingPainter( + private val painter: Painter, + private var alpha: Float, + private var colorFilter: ColorFilter?, + private val onDraw: DrawScope.(ForwardingDrawInfo) -> Unit, +) : Painter() { + + private var info = newInfo() + + override val intrinsicSize get() = painter.intrinsicSize + + override fun applyAlpha(alpha: Float): Boolean { + if (alpha != DefaultAlpha) { + this.alpha = alpha + this.info = newInfo() + } + return true + } + + override fun applyColorFilter(colorFilter: ColorFilter?): Boolean { + if (colorFilter != null) { + this.colorFilter = colorFilter + this.info = newInfo() + } + return true + } + + override fun DrawScope.onDraw() = onDraw(info) + + private fun newInfo() = ForwardingDrawInfo(painter, alpha, colorFilter) +} + +private val DefaultOnDraw: DrawScope.(ForwardingDrawInfo) -> Unit = { info -> + with(info.painter) { + draw(size, info.alpha, info.colorFilter) + } +} \ No newline at end of file diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/ScreenScaffold.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/ScreenScaffold.kt new file mode 100644 index 00000000..f903e95e --- /dev/null +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/ScreenScaffold.kt @@ -0,0 +1,82 @@ +package com.devbrackets.android.exomediademo.ui.support.compose + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.statusBars +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.devbrackets.android.exomediademo.ui.support.compose.theme.DemoTheme + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun ScreenScaffold( + title: String, + subTitle: String? = null, + onBackClick: (() -> Unit)? = null, + content: @Composable (PaddingValues) -> Unit +) { + DemoTheme { + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = title, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + AnimatedContent( + targetState = subTitle, + ) { target -> + target?.let { + Text( + text = it, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = DemoTheme.typography.bodyMedium + ) + } + } + } + }, + navigationIcon = { + onBackClick?.let { + IconButton( + onClick = it, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null + ) + } + } + }, + windowInsets = WindowInsets.statusBars + ) + }, + content = content + ) + } +} + +@Preview +@Composable +private fun PreviewScreenScaffold() { + ScreenScaffold( + title = "ExoMedia Demo App", + subTitle = "Sub-title", + onBackClick = {} + ) { } +} \ No newline at end of file diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/seek/Seekbar.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/seek/Seekbar.kt new file mode 100644 index 00000000..a7d4bc0b --- /dev/null +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/seek/Seekbar.kt @@ -0,0 +1,137 @@ +package com.devbrackets.android.exomediademo.ui.support.compose.seek + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.devbrackets.android.exomediademo.ui.support.compose.theme.DemoTheme +import kotlin.math.roundToLong + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun Seekbar( + position: State, + maxValue: State, + enabled: Boolean, + onSeek: (SeekState) -> Unit, + modifier: Modifier = Modifier, +) { + val seekState = remember { mutableStateOf(null) } + val derivedValue = remember { + derivedStateOf { + determineSeekbarValue(position.value, maxValue.value, seekState.value) + } + } + + Slider( + value = derivedValue.value, + onValueChange = { + determineSeekPosition(it, maxValue = maxValue.value)?.let { seekValue -> + val newState = when (seekState.value) { + null -> SeekState.Started(seekValue) + else -> SeekState.Seeking(seekValue) + } + + seekState.value = newState + onSeek(newState) + } + }, + modifier = modifier, + enabled = enabled, + onValueChangeFinished = { + seekState.value?.let { + onSeek(SeekState.Finished(it.position)) + } + + seekState.value = null + }, + track = { sliderState -> + SliderDefaults.Track( + colors = SliderDefaults.colors(), + drawStopIndicator = null, + sliderState = sliderState + ) + }, + ) +} + +@Immutable +sealed interface SeekState { + val position: Long + + @Immutable + data class Started(override val position: Long): SeekState + + @Immutable + data class Seeking(override val position: Long): SeekState + + @Immutable + data class Finished(override val position: Long): SeekState +} + +@Stable +private fun determineSeekbarValue( + position: Long?, + maxValue: Long?, + seekState: SeekState? +): Float { + if (position == null || maxValue == null || maxValue <= 0) { + return 0f + } + + val displayPosition = when (seekState) { + is SeekState.Started, is SeekState.Seeking -> seekState.position + else -> position + } + + return (displayPosition.toDouble() / maxValue).toFloat() +} + +@Stable +private fun determineSeekPosition( + interactionValue: Float?, + maxValue: Long? +): Long? { + if (interactionValue == null || maxValue == null) { + return null + } + + return (interactionValue.toDouble() * maxValue).roundToLong() +} + +@Composable +@Preview(showBackground = true) +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun PreviewSeekbar() { + val position = remember { mutableStateOf(254_000) } + + DemoTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Seekbar( + position = position, + maxValue = remember { mutableStateOf(490_000) }, + enabled = true, + onSeek = { + position.value = it.position + } + ) + } + } +} diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Color.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Color.kt new file mode 100644 index 00000000..65907b6c --- /dev/null +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Color.kt @@ -0,0 +1,37 @@ +package com.devbrackets.android.exomediademo.ui.support.compose.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF42A5F5), + secondary = Color(0xFF607D8B), + background = Color(0xFFEBEBF5), + surface = Color(0xFFEBEBF5) +) + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF42A5F5), + secondary = Color(0xFF607D8B), + background = Color(0xFF2A2A2B), + surface = Color(0xFF2A2A2B) +) + +internal val LocalColorScheme = staticCompositionLocalOf { DarkColorScheme } + +@Composable +@ReadOnlyComposable +internal fun getColorScheme( + darkTheme: Boolean = isSystemInDarkTheme() +): ColorScheme { + return when (darkTheme) { + true -> DarkColorScheme + false -> LightColorScheme + } +} diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Shape.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Shape.kt new file mode 100644 index 00000000..0682cbb1 --- /dev/null +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Shape.kt @@ -0,0 +1,6 @@ +package com.devbrackets.android.exomediademo.ui.support.compose.theme + +import androidx.compose.material3.Shapes +import androidx.compose.runtime.staticCompositionLocalOf + +internal val LocalShapes = staticCompositionLocalOf { Shapes() } diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Theme.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Theme.kt new file mode 100644 index 00000000..0e625868 --- /dev/null +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Theme.kt @@ -0,0 +1,51 @@ +package com.devbrackets.android.exomediademo.ui.support.compose.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember + +object DemoTheme { + val colors: ColorScheme + @Composable + @ReadOnlyComposable + get() = LocalColorScheme.current + + val typography: Typography + @Composable + @ReadOnlyComposable + get() = LocalTypography.current + + val shapes: Shapes + @Composable + @ReadOnlyComposable + get() = LocalShapes.current +} + +@Composable +fun DemoTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colorScheme = getColorScheme(darkTheme = darkTheme) + val typography = Typography + val shapes = remember { Shapes() } + + CompositionLocalProvider( + LocalColorScheme provides colorScheme, + LocalTypography provides typography, + LocalShapes provides shapes + ) { + MaterialTheme( + colorScheme = LocalColorScheme.current, + typography = LocalTypography.current, + shapes = LocalShapes.current, + content = content + ) + } +} \ No newline at end of file diff --git a/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Type.kt b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Type.kt new file mode 100644 index 00000000..efef50c1 --- /dev/null +++ b/demo/src/main/kotlin/com/devbrackets/android/exomediademo/ui/support/compose/theme/Type.kt @@ -0,0 +1,20 @@ +package com.devbrackets.android.exomediademo.ui.support.compose.theme + +import androidx.compose.material3.Typography +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) +) + +internal val LocalTypography = staticCompositionLocalOf { Typography } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a6a1d92b..8beea654 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ activityCompose = "1.9.3" androidGradlePlugin = "8.7.3" appcompat = "1.7.0" +coil = "3.0.4" compiler = "4.12.0" constraintlayout = "2.2.0" coreKtx = "1.15.0" @@ -9,7 +10,6 @@ glide = "4.16.0" junit = "4.13.2" kotlin = "2.0.20" leakcanaryAndroid = "2.7" -material = "1.7.6" media = "1.7.0" media3Exoplayer = "1.5.1" navigationCompose = "2.8.5" @@ -17,12 +17,18 @@ nexusPublish = "1.1.0" playlistcore = "2.1.0" robolectric = "4.13" composeUi = "1.7.6" +composeMaterial3 = "1.3.1" [libraries] activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } android-gradle = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "compiler" } +compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "composeMaterial3" } +compose-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "composeUi" } +compose-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "composeUi" } constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } @@ -30,9 +36,6 @@ junit = { module = "junit:junit", version.ref = "junit" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-stdlib-jdk7 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk7", version.ref = "kotlin" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroid" } -material = { module = "androidx.compose.material:material", version.ref = "material" } -material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "material" } -material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "material" } media = { module = "androidx.media:media", version.ref = "media" } media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3Exoplayer" } media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }