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:
austinried
2026-10-04 00:30:07 +00:00
parent 4ddbefe7cd
commit 317a8f4e09
2 changed files with 98 additions and 3 deletions
@@ -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)
}
}