diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index c9f0967f..1b8e96cf 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -1,8 +1,13 @@ package com.subtracks.ui.playback +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -53,6 +58,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.PlatformTextStyle @@ -81,11 +88,16 @@ import com.subtracks.ui.theme.ArtworkSeedCache import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.rememberArtworkColors +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import org.koin.compose.koinInject const val NOW_PLAYING_COVER_TAG = "now-playing-cover" +private const val SWIPE_THRESHOLD_FRACTION = 0.25f +private const val SWIPE_FLING_VELOCITY = 1000f +private const val SWIPE_SETTLE_MS = 180 + @Composable fun NowPlayingRoute( onBack: () -> Unit, @@ -200,6 +212,18 @@ fun NowPlayingScreen( platformStyle = PlatformTextStyle(includeFontPadding = false), lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both), ) + var swipeOffsetX by remember { mutableFloatStateOf(0f) } + var coverWidthPx by remember { mutableFloatStateOf(0f) } + var settleJob by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + fun settleSwipe() { + settleJob?.cancel() + settleJob = + scope.launch { + animate(swipeOffsetX, 0f, animationSpec = tween(SWIPE_SETTLE_MS)) { value, _ -> swipeOffsetX = value } + } + } CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { ArtworkTheme(artwork) { @@ -254,7 +278,21 @@ fun NowPlayingScreen( .weight(1f) .fillMaxWidth() .padding(top = 2.dp) - .clickable( + .onSizeChanged { coverWidthPx = it.width.toFloat() } + .graphicsLayer { translationX = swipeOffsetX } + .draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> swipeOffsetX += delta }, + onDragStarted = { settleJob?.cancel() }, + onDragStopped = { velocity -> + val threshold = coverWidthPx * SWIPE_THRESHOLD_FRACTION + when { + swipeOffsetX <= -threshold || velocity <= -SWIPE_FLING_VELOCITY -> onNext() + swipeOffsetX >= threshold || velocity >= SWIPE_FLING_VELOCITY -> onPrevious() + } + settleSwipe() + }, + ).clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, enabled = onAlbumClick != null, @@ -299,6 +337,8 @@ fun NowPlayingScreen( Modifier .weight(1f) .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, enabled = onArtistClick != null, onClickLabel = "Open artist", ) { onArtistClick?.invoke() }, diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingTapsTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingTapsTest.kt index 5b2ed230..b6c7395a 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingTapsTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingTapsTest.kt @@ -1,6 +1,7 @@ package com.subtracks.ui.playback import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertIsNotEnabled @@ -10,6 +11,10 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.test.swipeRight import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.subtracks.data.model.CoverArtRef @@ -48,6 +53,8 @@ class NowPlayingTapsTest { private fun render( onAlbumClick: (() -> Unit)? = null, onArtistClick: (() -> Unit)? = null, + onNext: () -> Unit = {}, + onPrevious: () -> Unit = {}, ) { composeRule.setContent { SubtracksTheme { @@ -60,8 +67,8 @@ class NowPlayingTapsTest { onBack = {}, onQueue = {}, onPlayPause = {}, - onNext = {}, - onPrevious = {}, + onNext = onNext, + onPrevious = onPrevious, onAlbumClick = onAlbumClick, onArtistClick = onArtistClick, onSeek = {}, @@ -126,4 +133,52 @@ class NowPlayingTapsTest { composeRule.onNodeWithTag(NOW_PLAYING_COVER_TAG).assertIsNotEnabled() composeRule.onNodeWithText("Radiohead").assertIsNotEnabled() } + + @Test + fun swipingLeftOnTheCoverSkipsToTheNextTrack() { + var next = false + var previous = false + render(onNext = { next = true }, onPrevious = { previous = true }) + + composeRule.onNodeWithTag(NOW_PLAYING_COVER_TAG).performTouchInput { swipeLeft() } + + assertTrue("a left swipe should skip to the next track", next) + assertTrue("a left swipe should not go to the previous track", !previous) + } + + @Test + fun swipingRightOnTheCoverGoesToThePreviousTrack() { + var next = false + var previous = false + render(onNext = { next = true }, onPrevious = { previous = true }) + + composeRule.onNodeWithTag(NOW_PLAYING_COVER_TAG).performTouchInput { swipeRight() } + + assertTrue("a right swipe should go to the previous track", previous) + assertTrue("a right swipe should not skip to the next track", !next) + } + + @Test + fun aShortSlowDragOnTheCoverDoesNotSkip() { + var next = false + var previous = false + render(onNext = { next = true }, onPrevious = { previous = true }) + + val cover = composeRule.onNodeWithTag(NOW_PLAYING_COVER_TAG) + val width = + cover + .fetchSemanticsNode() + .size.width + .toFloat() + cover.performTouchInput { + swipe( + start = Offset(width * 0.9f, centerY), + end = Offset(width * 0.7f, centerY), + durationMillis = 600, + ) + } + + assertTrue("a drag below the threshold should not skip", !next) + assertTrue("a drag below the threshold should not go to the previous track", !previous) + } }