mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
swipe between tracks on the now playing screen
Left/right drags on the cover art skip to the next/previous track, mirroring the transport buttons; the cover follows the finger and settles back on release. Also drops the ripple on the title/artist block.
This commit is contained in:
@@ -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<Job?>(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() },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user