From 80ffb837d20ef5363ec9852605b1629e04d81d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20=C4=90=E1=BB=A9c=20Tu=E1=BA=A5n=20Minh?= Date: Thu, 25 Apr 2024 16:43:52 +0700 Subject: [PATCH] Implemented swiping miniplayer actions --- .../com/maxrave/simpmusic/ui/MainActivity.kt | 12 +- .../ui/fragment/player/InfoFragment.kt | 4 +- .../ui/fragment/player/NowPlayingFragment.kt | 4 +- .../ui/fragment/player/QueueFragment.kt | 21 +- .../maxrave/simpmusic/ui/screen/MiniPlayer.kt | 224 ++++++++++++------ .../simpmusic/viewModel/SharedViewModel.kt | 8 +- 6 files changed, 186 insertions(+), 87 deletions(-) diff --git a/app/src/main/java/com/maxrave/simpmusic/ui/MainActivity.kt b/app/src/main/java/com/maxrave/simpmusic/ui/MainActivity.kt index 69796b5e..b8b7e197 100644 --- a/app/src/main/java/com/maxrave/simpmusic/ui/MainActivity.kt +++ b/app/src/main/java/com/maxrave/simpmusic/ui/MainActivity.kt @@ -424,7 +424,7 @@ class MainActivity : AppCompatActivity() { val navController = navHostFragment?.findNavController() binding.miniplayer.setContent { AppTheme { - MiniPlayer(sharedViewModel = viewModel) { + MiniPlayer(sharedViewModel = viewModel, onClose = { onCloseMiniplayer() }) { val bundle = Bundle() bundle.putString("type", Config.MINIPLAYER_CLICK) navController?.navigateSafe(R.id.action_global_nowPlayingFragment, bundle) @@ -687,6 +687,9 @@ class MainActivity : AppCompatActivity() { } } } + val job2 = launch { + viewModel + } job1.join() } lifecycleScope.launch { @@ -922,6 +925,13 @@ class MainActivity : AppCompatActivity() { super.onConfigurationChanged(newConfig) viewModel.activityRecreate() } + + fun onCloseMiniplayer() { + viewModel.stopPlayer() + viewModel.isServiceRunning.postValue(false) + viewModel.videoId.postValue(null) + binding.miniplayer.visibility = View.GONE + } } val LocalPlayerAwareWindowInsets = diff --git a/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/InfoFragment.kt b/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/InfoFragment.kt index 429c7584..b7c6893f 100644 --- a/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/InfoFragment.kt +++ b/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/InfoFragment.kt @@ -22,7 +22,9 @@ import com.maxrave.simpmusic.extension.navigateSafe import com.maxrave.simpmusic.extension.toListName import com.maxrave.simpmusic.viewModel.SharedViewModel import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking @AndroidEntryPoint class InfoFragment: BottomSheetDialogFragment(){ @@ -74,7 +76,7 @@ class InfoFragment: BottomSheetDialogFragment(){ @UnstableApi override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - if (viewModel.nowPlayingMediaItem.value != null) { + if (runBlocking { viewModel.nowPlayingMediaItem.first() } != null) { if (viewModel.simpleMediaServiceHandler != null) { val data = viewModel.simpleMediaServiceHandler!!.catalogMetadata[viewModel.getCurrentMediaItemIndex()] with(binding){ diff --git a/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/NowPlayingFragment.kt b/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/NowPlayingFragment.kt index 35f8982f..843e33ab 100644 --- a/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/NowPlayingFragment.kt +++ b/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/NowPlayingFragment.kt @@ -595,7 +595,7 @@ class NowPlayingFragment : Fragment() { // binding.progressSong.value = viewModel.progress.value * 100 // } if (videoId == null) { - videoId = viewModel.nowPlayingMediaItem.value?.mediaId + videoId = runBlocking { viewModel.nowPlayingMediaItem.first()?.mediaId } viewModel.videoId.postValue(videoId) } updateUIfromCurrentMediaItem(viewModel.getCurrentMediaItem()) @@ -1394,7 +1394,7 @@ class NowPlayingFragment : Fragment() { findNavController().navigateSafe(R.id.action_global_infoFragment) } binding.cbFavorite.setOnClickListener { - viewModel.nowPlayingMediaItem.value?.let { nowPlayingSong -> + runBlocking { viewModel.nowPlayingMediaItem.first() }?.let { nowPlayingSong -> viewModel.updateLikeStatus( nowPlayingSong.mediaId, !runBlocking { viewModel.liked.first() }, diff --git a/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/QueueFragment.kt b/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/QueueFragment.kt index 050dd92e..b8bca448 100644 --- a/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/QueueFragment.kt +++ b/app/src/main/java/com/maxrave/simpmusic/ui/fragment/player/QueueFragment.kt @@ -126,7 +126,15 @@ class QueueFragment: BottomSheetDialogFragment() { } } val job2 = launch { - updateNowPlaying() + viewModel.nowPlayingMediaItem.collect { + if (it != null){ + binding.ivThumbnail.load(it.mediaMetadata.artworkUri) + binding.tvSongTitle.text = it.mediaMetadata.title + binding.tvSongTitle.isSelected = true + binding.tvSongArtist.text = it.mediaMetadata.artist + binding.tvSongArtist.isSelected = true + } + } } val job3 = launch { viewModel.simpleMediaServiceHandler?.currentSongIndex?.collect{ index -> @@ -239,15 +247,4 @@ class QueueFragment: BottomSheetDialogFragment() { } }) } - private fun updateNowPlaying(){ - viewModel.nowPlayingMediaItem.observe(viewLifecycleOwner) { - if (it != null){ - binding.ivThumbnail.load(it.mediaMetadata.artworkUri) - binding.tvSongTitle.text = it.mediaMetadata.title - binding.tvSongTitle.isSelected = true - binding.tvSongArtist.text = it.mediaMetadata.artist - binding.tvSongArtist.isSelected = true - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt b/app/src/main/java/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index b9d6e29e..2da564e4 100644 --- a/app/src/main/java/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt +++ b/app/src/main/java/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt @@ -4,12 +4,15 @@ package com.maxrave.simpmusic.ui.screen import android.graphics.drawable.GradientDrawable import android.util.Log import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,6 +21,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -36,6 +40,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -43,10 +48,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.core.graphics.ColorUtils +import androidx.lifecycle.viewModelScope import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi import androidx.wear.compose.material3.ripple @@ -65,11 +74,13 @@ import com.skydoves.landscapist.palette.rememberPaletteState import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlin.math.roundToInt @Composable @UnstableApi fun MiniPlayer( sharedViewModel: SharedViewModel, + onClose: () -> Unit, onClick: () -> Unit, ) { val (mediaItem, setMediaItem) = remember { @@ -85,6 +96,8 @@ fun MiniPlayer( mutableFloatStateOf(0f) } + val coroutineScope = rememberCoroutineScope() + val animatedProgress by animateFloatAsState( targetValue = progress, animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, label = "", @@ -94,9 +107,13 @@ fun MiniPlayer( val (background, setBackground) = rememberSaveable { mutableIntStateOf(0x000000) } + + val offsetX = remember { Animatable(initialValue = 0f) } + val offsetY = remember { Animatable(0f) } + LaunchedEffect(key1 = true) { val job1 = launch { - sharedViewModel.simpleMediaServiceHandler?.nowPlaying?.collect { item -> + sharedViewModel.nowPlayingMediaItem.collect { item -> if (item != null) { setMediaItem(item) } @@ -149,10 +166,6 @@ fun MiniPlayer( } } } - Log.d( - "Check Start Color", - "transform: $startColor", - ) } val endColor = 0x1b1a1f val gd = @@ -175,12 +188,42 @@ fun MiniPlayer( colors = CardDefaults.elevatedCardColors( containerColor = Color(background), ), - modifier = Modifier.fillMaxHeight() - .clickable ( + modifier = Modifier + .fillMaxHeight() + .offset { IntOffset(0, offsetY.value.roundToInt()) } + .clickable( onClick = onClick, indication = ripple(), interactionSource = remember { MutableInteractionSource() } ) + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragStart = { + }, + onVerticalDrag = { change: PointerInputChange, dragAmount: Float -> + coroutineScope.launch { + change.consume() + offsetY.animateTo(offsetY.value + dragAmount) + Log.w("MiniPlayer", "Dragged ${offsetY.value}") + } + }, + onDragCancel = { + coroutineScope.launch { + offsetY.animateTo(0f) + } + }, + onDragEnd = { + Log.w("MiniPlayer", "Drag Ended") + coroutineScope.launch { + if (offsetY.value > 70) { + onClose() + } + offsetY.animateTo(0f) + } + } + ) + } + ) { Column(modifier = Modifier.fillMaxHeight()) { Row( @@ -189,68 +232,113 @@ fun MiniPlayer( .weight(1F) ) { Spacer(modifier = Modifier.size(8.dp)) - CoilImage( - imageModel = { mediaItem.mediaMetadata.artworkUri }, - imageOptions = - ImageOptions( - contentScale = ContentScale.Crop, - alignment = Alignment.Center, - ), - previewPlaceholder = painterResource(id = R.drawable.holder), - component = - rememberImageComponent { - add(CrossfadePlugin( - duration = 550, - )) - add(PalettePlugin { - palette = it - }) - }, - modifier = - Modifier - .size(40.dp) - .clip( - RoundedCornerShape(8.dp), - ), - ) - Spacer(modifier = Modifier.width(10.dp)) - Crossfade(targetState = mediaItem, modifier = Modifier.weight(1F)) { - if (it != MediaItem.EMPTY) { - Column { - Text( - text = (mediaItem.mediaMetadata.title ?: "").toString(), - style = typo.labelMedium, - color = Color.White, - maxLines = 1, - modifier = - Modifier - .fillMaxWidth() - .wrapContentHeight(align = Alignment.CenterVertically) - .basicMarquee(animationMode = MarqueeAnimationMode.Immediately) - .focusable(), - ) - Text( - text = (mediaItem.mediaMetadata.artist ?: "").toString(), - style = typo.bodySmall, - color = Color.White, - maxLines = 1, - modifier = - Modifier - .fillMaxWidth() - .wrapContentHeight(align = Alignment.CenterVertically) - .basicMarquee(animationMode = MarqueeAnimationMode.Immediately) - .focusable(), - ) + Box(modifier = Modifier.weight(1F)) { + Row( + modifier = Modifier + .offset { IntOffset(offsetX.value.roundToInt(), 0) } + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { + + }, + onHorizontalDrag = { change: PointerInputChange, dragAmount: Float -> + coroutineScope.launch { + change.consume() + offsetX.animateTo(offsetX.value + dragAmount) + Log.w("MiniPlayer", "Dragged ${offsetX.value}") + } + }, + onDragCancel = { + Log.w("MiniPlayer", "Drag Cancelled") + coroutineScope.launch { + if (offsetX.value > 250) { + sharedViewModel.onUIEvent(UIEvent.Next) + } else if (offsetX.value < -120) { + sharedViewModel.onUIEvent(UIEvent.Previous) + } + offsetX.animateTo(0f) + } + }, + onDragEnd = { + Log.w("MiniPlayer", "Drag Ended") + coroutineScope.launch { + if (offsetX.value > 250) { + sharedViewModel.onUIEvent(UIEvent.Next) + } else if (offsetX.value < -120) { + sharedViewModel.onUIEvent(UIEvent.Previous) + } + offsetX.animateTo(0f) + } + } + ) + } + ) { + CoilImage( + imageModel = { mediaItem.mediaMetadata.artworkUri }, + imageOptions = + ImageOptions( + contentScale = ContentScale.Crop, + alignment = Alignment.Center, + ), + previewPlaceholder = painterResource(id = R.drawable.holder), + component = + rememberImageComponent { + add(CrossfadePlugin( + duration = 550, + )) + add(PalettePlugin { + palette = it + }) + }, + modifier = + Modifier + .size(40.dp) + .clip( + RoundedCornerShape(8.dp), + ), + ) + Spacer(modifier = Modifier.width(10.dp)) + Crossfade(targetState = mediaItem, modifier = Modifier.weight(1F)) { + if (it != MediaItem.EMPTY) { + Column { + Text( + text = (mediaItem.mediaMetadata.title ?: "").toString(), + style = typo.labelMedium, + color = Color.White, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.CenterVertically) + .basicMarquee(animationMode = MarqueeAnimationMode.Immediately) + .focusable(), + ) + Text( + text = (mediaItem.mediaMetadata.artist ?: "").toString(), + style = typo.bodySmall, + color = Color.White, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(align = Alignment.CenterVertically) + .basicMarquee(animationMode = MarqueeAnimationMode.Immediately) + .focusable(), + ) + } + } } } } Spacer(modifier = Modifier.width(15.dp)) HeartCheckBox(checked = liked, size = 24) { - sharedViewModel.nowPlayingMediaItem.value?.let { nowPlayingSong -> - sharedViewModel.updateLikeStatus( - nowPlayingSong.mediaId, - !runBlocking { sharedViewModel.liked.first() }, - ) + sharedViewModel.viewModelScope.launch { + sharedViewModel.nowPlayingMediaItem.first()?.let { nowPlayingSong -> + sharedViewModel.updateLikeStatus( + nowPlayingSong.mediaId, + !runBlocking { sharedViewModel.liked.first() }, + ) + } } } Spacer(modifier = Modifier.width(15.dp)) @@ -259,9 +347,11 @@ fun MiniPlayer( } Spacer(modifier = Modifier.width(15.dp)) } - Box(modifier = Modifier.wrapContentSize(Alignment.Center).padding( - horizontal = 10.dp - )) { + Box(modifier = Modifier + .wrapContentSize(Alignment.Center) + .padding( + horizontal = 10.dp + )) { LinearProgressIndicator( progress = { animatedProgress }, modifier = Modifier diff --git a/app/src/main/java/com/maxrave/simpmusic/viewModel/SharedViewModel.kt b/app/src/main/java/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index 391dda8c..d5392029 100644 --- a/app/src/main/java/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/app/src/main/java/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -162,8 +162,8 @@ class SharedViewModel private var _translateLyrics: MutableStateFlow = MutableStateFlow(null) val translateLyrics: StateFlow = _translateLyrics - private var _nowPlayingMediaItem = MutableLiveData() - val nowPlayingMediaItem: LiveData = _nowPlayingMediaItem + private var _nowPlayingMediaItem = MutableStateFlow(MediaItem.EMPTY) + val nowPlayingMediaItem: SharedFlow = _nowPlayingMediaItem.asSharedFlow() private var _songTransitions = MutableStateFlow(false) val songTransitions: StateFlow = _songTransitions @@ -364,7 +364,7 @@ class SharedViewModel } } if (nowPlaying != null) { - _nowPlayingMediaItem.postValue(nowPlaying) + _nowPlayingMediaItem.emit(nowPlaying) var downloaded = false val tempSong = simpleMediaServiceHandler!!.catalogMetadata.getOrNull( @@ -1222,7 +1222,7 @@ class SharedViewModel fun refreshSongDB() { viewModelScope.launch { - nowPlayingMediaItem.value?.mediaId?.let { + nowPlayingMediaItem.first()?.mediaId?.let { mainRepository.getSongById(it).collect { songEntity -> _songDB.value = songEntity if (songEntity != null) {