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 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.background
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.clickable 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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -53,6 +58,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.LocalDensity
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.PlatformTextStyle 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.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.HeroGradient
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
const val NOW_PLAYING_COVER_TAG = "now-playing-cover" 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 @Composable
fun NowPlayingRoute( fun NowPlayingRoute(
onBack: () -> Unit, onBack: () -> Unit,
@@ -200,6 +212,18 @@ fun NowPlayingScreen(
platformStyle = PlatformTextStyle(includeFontPadding = false), platformStyle = PlatformTextStyle(includeFontPadding = false),
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both), 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) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) { ArtworkTheme(artwork) {
@@ -254,7 +278,21 @@ fun NowPlayingScreen(
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxWidth()
.padding(top = 2.dp) .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() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
enabled = onAlbumClick != null, enabled = onAlbumClick != null,
@@ -299,6 +337,8 @@ fun NowPlayingScreen(
Modifier Modifier
.weight(1f) .weight(1f)
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = onArtistClick != null, enabled = onArtistClick != null,
onClickLabel = "Open artist", onClickLabel = "Open artist",
) { onArtistClick?.invoke() }, ) { onArtistClick?.invoke() },
@@ -1,6 +1,7 @@
package com.subtracks.ui.playback package com.subtracks.ui.playback
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.assertIsNotEnabled 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.onNodeWithText
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick 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.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
@@ -48,6 +53,8 @@ class NowPlayingTapsTest {
private fun render( private fun render(
onAlbumClick: (() -> Unit)? = null, onAlbumClick: (() -> Unit)? = null,
onArtistClick: (() -> Unit)? = null, onArtistClick: (() -> Unit)? = null,
onNext: () -> Unit = {},
onPrevious: () -> Unit = {},
) { ) {
composeRule.setContent { composeRule.setContent {
SubtracksTheme { SubtracksTheme {
@@ -60,8 +67,8 @@ class NowPlayingTapsTest {
onBack = {}, onBack = {},
onQueue = {}, onQueue = {},
onPlayPause = {}, onPlayPause = {},
onNext = {}, onNext = onNext,
onPrevious = {}, onPrevious = onPrevious,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onSeek = {}, onSeek = {},
@@ -126,4 +133,52 @@ class NowPlayingTapsTest {
composeRule.onNodeWithTag(NOW_PLAYING_COVER_TAG).assertIsNotEnabled() composeRule.onNodeWithTag(NOW_PLAYING_COVER_TAG).assertIsNotEnabled()
composeRule.onNodeWithText("Radiohead").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)
}
} }