From 6474b52490c9a34bc91e3f0d91881f7d5565107c Mon Sep 17 00:00:00 2001 From: vfsfitvnm Date: Fri, 22 Jul 2022 21:08:31 +0200 Subject: [PATCH] Implement #101 --- .../it/vfsfitvnm/vimusic/MainActivity.kt | 81 ++++-- .../vimusic/ui/components/BottomSheet.kt | 259 +++++++++--------- .../vimusic/ui/views/CurrentPlaylistView.kt | 2 +- .../vimusic/ui/views/PlayerBottomSheet.kt | 8 +- .../vfsfitvnm/vimusic/ui/views/PlayerView.kt | 152 +++++----- 5 files changed, 261 insertions(+), 241 deletions(-) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt index 6a618fe..bd71a9e 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt @@ -1,6 +1,5 @@ package it.vfsfitvnm.vimusic -import android.annotation.SuppressLint import android.content.* import android.net.Uri import android.os.Bundle @@ -24,7 +23,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.text.ExperimentalTextApi +import androidx.media3.common.MediaItem +import androidx.media3.common.Player import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.valentinilk.shimmer.LocalShimmerTheme import com.valentinilk.shimmer.defaultShimmerTheme @@ -43,7 +43,6 @@ import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance import it.vfsfitvnm.vimusic.ui.views.PlayerView import it.vfsfitvnm.vimusic.utils.* - class MainActivity : ComponentActivity() { private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { @@ -70,10 +69,7 @@ class MainActivity : ComponentActivity() { super.onStop() } - @SuppressLint("BatteryLife") - @OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class, - ExperimentalTextApi::class - ) + @OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -85,7 +81,8 @@ class MainActivity : ComponentActivity() { var appearance by remember(isSystemInDarkTheme) { with(preferences) { val colorPaletteMode = getEnum(colorPaletteModeKey, ColorPaletteMode.System) - val thumbnailRoundness = getEnum(thumbnailRoundnessKey, ThumbnailRoundness.Light) + val thumbnailRoundness = + getEnum(thumbnailRoundnessKey, ThumbnailRoundness.Light) mutableStateOf( Appearance( @@ -102,7 +99,8 @@ class MainActivity : ComponentActivity() { SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> when (key) { colorPaletteModeKey -> { - val colorPaletteMode = sharedPreferences.getEnum(key, ColorPaletteMode.System) + val colorPaletteMode = + sharedPreferences.getEnum(key, ColorPaletteMode.System) appearance = appearance.copy( colorPalette = colorPaletteMode.palette(isSystemInDarkTheme), @@ -110,7 +108,8 @@ class MainActivity : ComponentActivity() { ) } thumbnailRoundnessKey -> { - val thumbnailRoundness = sharedPreferences.getEnum(key, ThumbnailRoundness.Light) + val thumbnailRoundness = + sharedPreferences.getEnum(key, ThumbnailRoundness.Light) appearance = appearance.copy( thumbnailShape = thumbnailRoundness.shape() @@ -130,21 +129,22 @@ class MainActivity : ComponentActivity() { val systemUiController = rememberSystemUiController() - val rippleTheme = remember(appearance.colorPalette.text, appearance.colorPalette.isDark) { - object : RippleTheme { - @Composable - override fun defaultColor(): Color = RippleTheme.defaultRippleColor( - contentColor = appearance.colorPalette.text, - lightTheme = !appearance.colorPalette.isDark - ) + val rippleTheme = + remember(appearance.colorPalette.text, appearance.colorPalette.isDark) { + object : RippleTheme { + @Composable + override fun defaultColor(): Color = RippleTheme.defaultRippleColor( + contentColor = appearance.colorPalette.text, + lightTheme = !appearance.colorPalette.isDark + ) - @Composable - override fun rippleAlpha(): RippleAlpha = RippleTheme.defaultRippleAlpha( - contentColor = appearance.colorPalette.text, - lightTheme = !appearance.colorPalette.isDark - ) + @Composable + override fun rippleAlpha(): RippleAlpha = RippleTheme.defaultRippleAlpha( + contentColor = appearance.colorPalette.text, + lightTheme = !appearance.colorPalette.isDark + ) + } } - } val shimmerTheme = remember { defaultShimmerTheme.copy( @@ -165,7 +165,10 @@ class MainActivity : ComponentActivity() { } SideEffect { - systemUiController.setSystemBarsColor(appearance.colorPalette.background, !appearance.colorPalette.isDark) + systemUiController.setSystemBarsColor( + appearance.colorPalette.background, + !appearance.colorPalette.isDark + ) } CompositionLocalProvider( @@ -185,15 +188,26 @@ class MainActivity : ComponentActivity() { ) { when (val uri = uri) { null -> { + val playerBottomSheetState = rememberBottomSheetState( + lowerBound = Dimensions.collapsedPlayer, upperBound = maxHeight + ) + HomeScreen() PlayerView( - layoutState = rememberBottomSheetState( - lowerBound = Dimensions.collapsedPlayer, upperBound = maxHeight - ), + layoutState = playerBottomSheetState, modifier = Modifier .align(Alignment.BottomCenter) ) + + binder?.player?.let { player -> + ExpandPlayerOnPlaylistChange( + player = player, + expand = { + playerBottomSheetState.expand(tween(500)) + } + ) + } } else -> IntentUriScreen(uri = uri) } @@ -215,3 +229,16 @@ class MainActivity : ComponentActivity() { } val LocalPlayerServiceBinder = staticCompositionLocalOf { null } + +@Composable +fun ExpandPlayerOnPlaylistChange(player: Player, expand: () -> Unit) { + DisposableEffect(player, expand) { + player.listener(object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) { + expand() + } + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt index 4a9ba52..d2e2129 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt @@ -1,22 +1,20 @@ package it.vfsfitvnm.vimusic.ui.components import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.* +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.DraggableState import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.pointer.pointerInput @@ -27,65 +25,90 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlin.math.absoluteValue - @Composable fun BottomSheet( state: BottomSheetState, modifier: Modifier = Modifier, peekHeight: Dp = 0.dp, elevation: Dp = 8.dp, - shape: Shape = RectangleShape, - handleOutsideInteractionsWhenExpanded: Boolean = false, collapsedContent: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit ) { - Box { - if (handleOutsideInteractionsWhenExpanded && !state.isCollapsed) { - Spacer( - modifier = Modifier - .pointerInput(state) { - detectTapGestures { - state.collapse() + Box( + modifier = modifier + .offset { + val y = (state.upperBound - state.value + peekHeight) + .roundToPx() + .coerceAtLeast(0) + IntOffset(x = 0, y = y) + } + .shadow(elevation = elevation) + .pointerInput(state) { + var initialValue = 0.dp + val velocityTracker = VelocityTracker() + + detectVerticalDragGestures( + onDragStart = { + initialValue = state.value + }, + onVerticalDrag = { change, dragAmount -> + velocityTracker.addPointerInputChange(change) + state.dispatchRawDelta(dragAmount) + }, + onDragEnd = { + val velocity = velocityTracker.calculateVelocity().y.absoluteValue + velocityTracker.resetTracking() + + if (velocity.absoluteValue > 300 && initialValue != state.value) { + if (initialValue > state.value) { + state.collapse() + } else { + state.expand() + } + } else { + if (state.upperBound - state.value > state.value - state.lowerBound) { + state.collapse() + } else { + state.expand() + } } } - .draggableBottomSheet(state) - .drawBehind { - drawRect(color = Color.Black.copy(alpha = 0.5f * state.progress)) + ) + } + .pointerInput(state) { + if (!state.isRunning && state.isCollapsed) { + detectTapGestures { + state.expand() } - .fillMaxSize() - ) + } + } + .fillMaxSize() + ) { + if (!state.isCollapsed) { + BackHandler(onBack = state::collapseSoft) + content() } - Box( - modifier = modifier - .offset { - val y = (state.upperBound - state.value + peekHeight) - .roundToPx() - .coerceAtLeast(0) - IntOffset(x = 0, y = y) - } - .shadow(elevation = elevation, shape = shape) - .clip(shape) - .draggableBottomSheet(state) - .pointerInput(state) { - if (!state.isRunning && state.isCollapsed) { - detectTapGestures { - state.expand() - } + if (!state.isExpanded) { + Box( + modifier = Modifier + .graphicsLayer { + alpha = 1f - (state.progress * 16).coerceAtMost(1f) } - } - .fillMaxSize() - ) { - if (!state.isCollapsed) { - BackHandler(onBack = state.collapse) - content() - } - - collapsedContent() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = true), + onClick = state::expandSoft + ) + .fillMaxWidth() + .height(state.lowerBound), + content = collapsedContent + ) } } } @@ -93,25 +116,63 @@ fun BottomSheet( @Stable class BottomSheetState( draggableState: DraggableState, - valueState: State, - isRunningState: State, - isCollapsedState: State, - isExpandedState: State, - progressState: State, - val lowerBound: Dp, - val upperBound: Dp, - val collapse: () -> Unit, - val expand: () -> Unit, + private val coroutineScope: CoroutineScope, + private val animatable: Animatable, + private val onWasExpandedChanged: (Boolean) -> Unit, ) : DraggableState by draggableState { - val value by valueState + val lowerBound: Dp + get() = animatable.lowerBound!! - val isRunning by isRunningState + val upperBound: Dp + get() = animatable.upperBound!! - val isCollapsed by isCollapsedState + val value by animatable.asState() - val isExpanded by isExpandedState + val isRunning by derivedStateOf { + animatable.isRunning + } - val progress by progressState + val isCollapsed by derivedStateOf { + value == animatable.lowerBound + } + + val isExpanded by derivedStateOf { + value == animatable.upperBound + } + + val progress by derivedStateOf { + 1f - (animatable.upperBound!! - animatable.value) / (animatable.upperBound!! - animatable.lowerBound!!) + } + + fun collapse(animationSpec: AnimationSpec) { + onWasExpandedChanged(false) + coroutineScope.launch { + animatable.animateTo(animatable.lowerBound!!, animationSpec) + } + } + + fun expand(animationSpec: AnimationSpec) { + onWasExpandedChanged(true) + coroutineScope.launch { + animatable.animateTo(animatable.upperBound!!, animationSpec) + } + } + + fun collapse() { + collapse(SpringSpec()) + } + + fun expand() { + expand(SpringSpec()) + } + + fun collapseSoft() { + collapse(tween(300)) + } + + fun expandSoft() { + expand(tween(300)) + } fun nestedScrollConnection(initialIsTopReached: Boolean = true): NestedScrollConnection { return object : NestedScrollConnection { @@ -148,7 +209,7 @@ class BottomSheetState( if (available.y.absoluteValue > 1000) { collapse() } else { - if (upperBound - value > value - lowerBound) { + if (animatable.upperBound!! - value > value - animatable.lowerBound!!) { collapse() } else { expand() @@ -179,79 +240,23 @@ fun rememberBottomSheetState(lowerBound: Dp, upperBound: Dp): BottomSheetState { mutableStateOf(false) } - val animatable = remember(lowerBound, upperBound) { - Animatable(if (wasExpanded) upperBound else lowerBound, Dp.VectorConverter).also { - it.updateBounds(lowerBound.coerceAtMost(upperBound), upperBound) - } - } + return remember(lowerBound, upperBound, coroutineScope) { + val animatable = + Animatable(if (wasExpanded) upperBound else lowerBound, Dp.VectorConverter).also { + it.updateBounds(lowerBound.coerceAtMost(upperBound), upperBound) + } - return remember(animatable, coroutineScope) { BottomSheetState( draggableState = DraggableState { delta -> coroutineScope.launch { animatable.snapTo(animatable.value - with(density) { delta.toDp() }) } }, - valueState = animatable.asState(), - lowerBound = lowerBound, - upperBound = upperBound, - isRunningState = derivedStateOf { - animatable.isRunning + onWasExpandedChanged = { + wasExpanded = it }, - isCollapsedState = derivedStateOf { - animatable.value == lowerBound - }, - isExpandedState = derivedStateOf { - animatable.value == upperBound - }, - progressState = derivedStateOf { - 1f - (upperBound - animatable.value) / (upperBound - lowerBound) - }, - collapse = { - wasExpanded = false - coroutineScope.launch { - animatable.animateTo(animatable.lowerBound!!) - } - }, - expand = { - wasExpanded = true - coroutineScope.launch { - animatable.animateTo(animatable.upperBound!!) - } - } + coroutineScope = coroutineScope, + animatable = animatable ) } } - -private fun Modifier.draggableBottomSheet(state: BottomSheetState) = pointerInput(state) { - var initialValue = 0.dp - val velocityTracker = VelocityTracker() - - detectVerticalDragGestures( - onDragStart = { - initialValue = state.value - }, - onVerticalDrag = { change, dragAmount -> - velocityTracker.addPointerInputChange(change) - state.dispatchRawDelta(dragAmount) - }, - onDragEnd = { - val velocity = velocityTracker.calculateVelocity().y.absoluteValue - velocityTracker.resetTracking() - - if (velocity.absoluteValue > 300 && initialValue != state.value) { - if (initialValue > state.value) { - state.collapse() - } else { - state.expand() - } - } else { - if (state.upperBound - state.value > state.value - state.lowerBound) { - state.collapse() - } else { - state.expand() - } - } - } - ) -} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt index cba2bb1..dc417c1 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt @@ -196,7 +196,7 @@ fun CurrentPlaylistView( .clickable( indication = rememberRipple(bounded = true), interactionSource = remember { MutableInteractionSource() }, - onClick = layoutState.collapse + onClick = layoutState::collapseSoft ) .shadow(elevation = 8.dp) .height(64.dp) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt index 0a1d7b9..3b6eff6 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import it.vfsfitvnm.vimusic.R @@ -17,7 +16,6 @@ import it.vfsfitvnm.vimusic.ui.components.BottomSheet import it.vfsfitvnm.vimusic.ui.components.BottomSheetState import it.vfsfitvnm.vimusic.ui.styling.LocalAppearance - @ExperimentalAnimationApi @Composable fun PlayerBottomSheet( @@ -38,11 +36,7 @@ fun PlayerBottomSheet( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .graphicsLayer { - alpha = 1f - (layoutState.progress * 16).coerceAtMost(1f) - } - .fillMaxWidth() - .height(layoutState.lowerBound) + .fillMaxSize() .background(colorPalette.background) ) { Row( diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt index b8780bc..8976f85 100644 --- a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt @@ -91,90 +91,84 @@ fun PlayerView( state = layoutState, modifier = modifier, collapsedContent = { - if (!layoutState.isExpanded) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background(colorPalette.elevatedBackground) + .fillMaxSize() + .drawBehind { + val progress = positionAndDuration.first.toFloat() / positionAndDuration.second.absoluteValue + val offset = Dimensions.thumbnails.player.songPreview.toPx() + + drawLine( + color = colorPalette.text, + start = Offset( + x = offset, + y = 1.dp.toPx() + ), + end = Offset( + x = ((size.width - offset) * progress) + offset, + y = 1.dp.toPx() + ), + strokeWidth = 2.dp.toPx() + ) + } + ) { + AsyncImage( + model = mediaItem.mediaMetadata.artworkUri.thumbnail(Dimensions.thumbnails.player.songPreview.px), + contentDescription = null, + contentScale = ContentScale.Crop, modifier = Modifier - .height(layoutState.lowerBound) - .fillMaxWidth() - .graphicsLayer { - alpha = 1f - (layoutState.progress * 16).coerceAtMost(1f) - } - .background(colorPalette.elevatedBackground) - .drawBehind { - val progress = positionAndDuration.first.toFloat() / positionAndDuration.second.absoluteValue - val offset = Dimensions.thumbnails.player.songPreview.toPx() + .size(Dimensions.thumbnails.player.songPreview) + ) - drawLine( - color = colorPalette.text, - start = Offset( - x = offset, - y = 1.dp.toPx() - ), - end = Offset( - x = ((size.width - offset) * progress) + offset, - y = 1.dp.toPx() - ), - strokeWidth = 2.dp.toPx() - ) - } + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .weight(1f) ) { - AsyncImage( - model = mediaItem.mediaMetadata.artworkUri.thumbnail(Dimensions.thumbnails.player.songPreview.px), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(Dimensions.thumbnails.player.songPreview) + BasicText( + text = mediaItem.mediaMetadata.title?.toString() ?: "", + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) + BasicText( + text = mediaItem.mediaMetadata.artist?.toString() ?: "", + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } - Column( - verticalArrangement = Arrangement.Center, + if (shouldBePlaying) { + Image( + painter = painterResource(R.drawable.pause), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier - .weight(1f) - ) { - BasicText( - text = mediaItem.mediaMetadata.title?.toString() ?: "", - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BasicText( - text = mediaItem.mediaMetadata.artist?.toString() ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - if (shouldBePlaying) { - Image( - painter = painterResource(R.drawable.pause), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable(onClick = binder.player::pause) - .padding(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(22.dp) - ) - } else { - Image( - painter = painterResource(R.drawable.play), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .clickable { - if (binder.player.playbackState == Player.STATE_IDLE) { - binder.player.prepare() - } - binder.player.play() + .clickable(onClick = binder.player::pause) + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .size(22.dp) + ) + } else { + Image( + painter = painterResource(R.drawable.play), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + if (binder.player.playbackState == Player.STATE_IDLE) { + binder.player.prepare() } - .padding(vertical = 8.dp) - .padding(horizontal = 16.dp) - .size(22.dp) - ) - } + binder.player.play() + } + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .size(22.dp) + ) } } } @@ -322,7 +316,7 @@ fun PlayerView( }, onSetSleepTimer = {}, onDismiss = menuState::hide, - onGlobalRouteEmitted = layoutState.collapse, + onGlobalRouteEmitted = layoutState::collapseSoft, ) } } @@ -341,7 +335,7 @@ fun PlayerView( isShowingLyrics = false isShowingStatsForNerds = !isShowingStatsForNerds }, - onGlobalRouteEmitted = layoutState.collapse, + onGlobalRouteEmitted = layoutState::collapseSoft, modifier = Modifier .align(Alignment.BottomCenter) )