peek the adjacent artwork while swiping the now playing screen

Resolve the previous/next queue items and render a three-slot cover strip that follows the drag, so the neighbouring cover slides in from the side on release. The strip is rotated optimistically at commit and hands back to live state once the controller reports the new track (or after a grace period), so a prop update mid-animation cannot desync it. Ratio prefetch and layered cover loading keep the incoming art from snapping as it loads.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent 317a8f4e09
commit b4539b9ba5
5 changed files with 362 additions and 25 deletions
@@ -25,6 +25,8 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.random.Random import kotlin.random.Random
internal const val PLAYBACK_RESTART_THRESHOLD_MS = 3_000L
data class QueueItem( data class QueueItem(
val id: String, val id: String,
val title: String, val title: String,
@@ -745,7 +747,7 @@ class PlaybackController(
fun previous() { fun previous() {
val player = player ?: return val player = player ?: return
if (player.currentPositionMs > RESTART_THRESHOLD_MS) { if (player.currentPositionMs > PLAYBACK_RESTART_THRESHOLD_MS) {
scope.launch { scope.launch {
startLock.withLock { player.seekTo(0) } startLock.withLock { player.seekTo(0) }
refresh() refresh()
@@ -901,6 +903,21 @@ class PlaybackController(
return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem() return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem()
} }
suspend fun previousItem(): QueueItem? {
var current: QueueSnapshot? = null
var position: Long? = null
var restarting = false
startLock.withLock {
current = snapshot
position = currentPosition()
restarting = (player?.currentPositionMs ?: 0L) > PLAYBACK_RESTART_THRESHOLD_MS
}
val snapshot = current ?: return null
val at = position ?: return null
if (at <= 0 || restarting) return null
return queueRepository.itemAt(snapshot, at - 1)?.toQueueItem()
}
private fun play( private fun play(
entries: List<QueueEntry>, entries: List<QueueEntry>,
startPosition: Long, startPosition: Long,
@@ -1496,7 +1513,6 @@ class PlaybackController(
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
const val QUEUE_WINDOW_RADIUS = 25L const val QUEUE_WINDOW_RADIUS = 25L
const val WINDOW_SHIFT_DELAY_MS = 400L const val WINDOW_SHIFT_DELAY_MS = 400L
const val RESTART_THRESHOLD_MS = 3_000L
const val SEED_PIN_TRIES = 1_000_000 const val SEED_PIN_TRIES = 1_000_000
const val FILE_SCHEME = "file:" const val FILE_SCHEME = "file:"
@@ -41,19 +41,23 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.SingletonImageLoader
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext import coil3.compose.LocalPlatformContext
import coil3.compose.rememberAsyncImagePainter import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.crossfade import coil3.request.crossfade
import coil3.size.Dimension import coil3.size.Dimension
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.theme.artworkColorsFromSeed import com.subtracks.ui.theme.artworkColorsFromSeed
import com.subtracks.ui.theme.placeholderSeed import com.subtracks.ui.theme.placeholderSeed
import kotlinx.coroutines.CancellationException
import java.lang.Character.UnicodeScript import java.lang.Character.UnicodeScript
private const val MAX_CACHED_RATIOS = 256 private const val MAX_CACHED_RATIOS = 256
private const val COVER_ART_FADE_MILLIS = 100 private const val COVER_ART_FADE_MILLIS = 100
private const val COVER_RATIO_PREFETCH_PX = 512
private const val IDEOGRAPHIC_INITIAL_LIFT = 0.05f private const val IDEOGRAPHIC_INITIAL_LIFT = 0.05f
private object ArtworkRatioCache { private object ArtworkRatioCache {
@@ -246,6 +250,31 @@ private fun BoxScope.CoverArtContent(
private fun Size.ratioOrNull(): Float? = if (width > 0f && height > 0f && width.isFinite() && height.isFinite()) width / height else null private fun Size.ratioOrNull(): Float? = if (width > 0f && height > 0f && width.isFinite() && height.isFinite()) width / height else null
internal suspend fun prefetchArtworkRatio(
context: Context,
ref: CoverArtRef,
) {
if (ArtworkRatioCache.get(ref.cacheKey) != null) return
val request =
ImageRequest
.Builder(context)
.data(ref.url)
.diskCacheKey(ref.cacheKey)
.size(COVER_RATIO_PREFETCH_PX)
.build()
val image =
try {
(SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
null
} ?: return
if (image.width > 0 && image.height > 0) {
ArtworkRatioCache.put(ref.cacheKey, image.width.toFloat() / image.height)
}
}
internal fun String.isIdeographicInitial(): Boolean { internal fun String.isIdeographicInitial(): Boolean {
if (isEmpty()) return false if (isEmpty()) return false
return when (UnicodeScript.of(codePointAt(0))) { return when (UnicodeScript.of(codePointAt(0))) {
@@ -57,6 +57,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.draw.clipToBounds
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
@@ -76,27 +77,81 @@ import com.subtracks.data.model.Song
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PLAYBACK_RESTART_THRESHOLD_MS
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.playback.RepeatMode import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.prefetchArtworkRatio
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkSeedCache 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.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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"
const val NOW_PLAYING_PREVIOUS_TAG = "now-playing-previous"
const val NOW_PLAYING_NEXT_TAG = "now-playing-next"
private const val SWIPE_THRESHOLD_FRACTION = 0.25f private const val SWIPE_THRESHOLD_FRACTION = 0.25f
private const val SWIPE_FLING_VELOCITY = 1000f private const val SWIPE_FLING_VELOCITY = 1000f
private const val SWIPE_SETTLE_MS = 180 private const val SWIPE_SETTLE_MS = 180
private const val SWIPE_EDGE_RESISTANCE = 0.3f
private const val HAND_BACK_TIMEOUT_MS = 750L
data class NowPlayingArt(
val id: String?,
val ref: CoverArtRef?,
val thumbnailRef: CoverArtRef?,
val name: String,
)
private data class AdjacentArt(
val previous: NowPlayingArt? = null,
val next: NowPlayingArt? = null,
)
internal enum class SwipeAction { CommitNext, CommitPrevious, Next, Previous, None }
internal fun swipeAction(
offsetX: Float,
velocity: Float,
width: Float,
canGoNext: Boolean,
canGoPrevious: Boolean,
): SwipeAction {
if (width <= 0f) return SwipeAction.None
val threshold = width * SWIPE_THRESHOLD_FRACTION
val wantsNext = offsetX <= -threshold || velocity <= -SWIPE_FLING_VELOCITY
val wantsPrevious = offsetX >= threshold || velocity >= SWIPE_FLING_VELOCITY
return when {
wantsNext && canGoNext -> SwipeAction.CommitNext
wantsPrevious && canGoPrevious -> SwipeAction.CommitPrevious
wantsNext -> SwipeAction.Next
wantsPrevious -> SwipeAction.Previous
else -> SwipeAction.None
}
}
internal fun rotateStrip(
base: List<NowPlayingArt?>,
direction: Int,
): List<NowPlayingArt?> =
if (direction > 0) {
listOf(base.getOrNull(1), base.getOrNull(2), null)
} else {
listOf(null, base.getOrNull(0), base.getOrNull(1))
}
@Composable @Composable
fun NowPlayingRoute( fun NowPlayingRoute(
@@ -147,10 +202,27 @@ fun NowPlayingRoute(
LaunchedEffect(queueContext) { LaunchedEffect(queueContext) {
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
} }
LaunchedEffect(state.item?.id, state.hasNext) {
if (!state.hasNext) return@LaunchedEffect fun artFor(item: QueueItem) =
val next = controller.upcomingItem() ?: return@LaunchedEffect NowPlayingArt(
controller.coverArt(next, thumbnail = true)?.let { ArtworkSeedCache.prefetch(context, it) } id = item.id,
ref = controller.coverArt(item),
thumbnailRef = controller.coverArt(item, thumbnail = true),
name = item.title,
)
var adjacent by remember { mutableStateOf(AdjacentArt()) }
val previousReachable = positionMs <= PLAYBACK_RESTART_THRESHOLD_MS
LaunchedEffect(state.item?.id, state.layout, previousReachable) {
if (state.item == null) {
adjacent = AdjacentArt()
return@LaunchedEffect
}
val previous = controller.previousItem()?.let { artFor(it) }
val next = controller.upcomingItem()?.let { artFor(it) }
adjacent = AdjacentArt(previous, next)
val thumbnails = listOfNotNull(previous?.thumbnailRef, next?.thumbnailRef)
withContext(Dispatchers.IO) { thumbnails.forEach { prefetchArtworkRatio(context, it) } }
next?.thumbnailRef?.let { ArtworkSeedCache.prefetch(context, it) }
} }
NowPlayingScreen( NowPlayingScreen(
state = state, state = state,
@@ -158,6 +230,8 @@ fun NowPlayingRoute(
title = sourceTitle.value, title = sourceTitle.value,
coverArt = art, coverArt = art,
thumbnailRef = thumbnail, thumbnailRef = thumbnail,
previousArt = if (previousReachable) adjacent.previous else null,
nextArt = adjacent.next,
artwork = rememberArtworkColors(thumbnail ?: art, fallbackName = state.item?.title), artwork = rememberArtworkColors(thumbnail ?: art, fallbackName = state.item?.title),
onBack = onBack, onBack = onBack,
onQueue = onQueue, onQueue = onQueue,
@@ -198,6 +272,8 @@ fun NowPlayingScreen(
onToggleStar: (() -> Unit)? = null, onToggleStar: (() -> Unit)? = null,
onSeek: (Long) -> Unit, onSeek: (Long) -> Unit,
thumbnailRef: CoverArtRef? = null, thumbnailRef: CoverArtRef? = null,
previousArt: NowPlayingArt? = null,
nextArt: NowPlayingArt? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val playButtonSize = 90.dp val playButtonSize = 90.dp
@@ -212,19 +288,52 @@ 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),
) )
val currentArt =
NowPlayingArt(
id = state.item?.id,
ref = coverArt,
thumbnailRef = thumbnailRef,
name = state.item?.title.orEmpty(),
)
val liveStrip = listOf(previousArt, currentArt, nextArt)
var frozenStrip by remember { mutableStateOf<List<NowPlayingArt?>?>(null) }
var dragging by remember { mutableStateOf(false) }
var swipeOffsetX by remember { mutableFloatStateOf(0f) } var swipeOffsetX by remember { mutableFloatStateOf(0f) }
var coverWidthPx by remember { mutableFloatStateOf(0f) } var coverWidthPx by remember { mutableFloatStateOf(0f) }
var settleJob by remember { mutableStateOf<Job?>(null) } var settleJob by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val strip = frozenStrip ?: liveStrip
val canGoNext = strip.getOrNull(2) != null
val canGoPrevious = strip.getOrNull(0) != null
fun settleSwipe() { fun animateSwipe(target: Float) {
settleJob?.cancel() settleJob?.cancel()
settleJob = settleJob =
scope.launch { scope.launch {
animate(swipeOffsetX, 0f, animationSpec = tween(SWIPE_SETTLE_MS)) { value, _ -> swipeOffsetX = value } animate(swipeOffsetX, target, animationSpec = tween(SWIPE_SETTLE_MS)) { value, _ -> swipeOffsetX = value }
} }
} }
fun commitSwipe(direction: Int) {
frozenStrip = rotateStrip(strip, direction)
swipeOffsetX += direction * coverWidthPx
if (direction > 0) onNext() else onPrevious()
animateSwipe(0f)
}
// The rotated strip keeps the slide continuous while the controller catches up. Hand back once
// it reports the target track, or after a grace period when the action did not move there (a
// restart, an offline skip), so a frozen strip can never stay desynced from playback.
LaunchedEffect(state.item?.id, frozenStrip, dragging) {
val pending = frozenStrip ?: return@LaunchedEffect
if (pending.getOrNull(1)?.id == state.item?.id) {
frozenStrip = null
} else if (!dragging) {
delay(HAND_BACK_TIMEOUT_MS)
if (frozenStrip === pending) frozenStrip = null
}
}
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
ArtworkTheme(artwork) { ArtworkTheme(artwork) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
@@ -278,19 +387,49 @@ fun NowPlayingScreen(
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxWidth()
.padding(top = 2.dp) .padding(top = 2.dp)
.clipToBounds()
.onSizeChanged { coverWidthPx = it.width.toFloat() } .onSizeChanged { coverWidthPx = it.width.toFloat() }
.graphicsLayer { translationX = swipeOffsetX }
.draggable( .draggable(
orientation = Orientation.Horizontal, orientation = Orientation.Horizontal,
state = rememberDraggableState { delta -> swipeOffsetX += delta }, state =
onDragStarted = { settleJob?.cancel() }, rememberDraggableState { delta ->
val resisted =
when {
delta > 0 && !canGoPrevious -> delta * SWIPE_EDGE_RESISTANCE
delta < 0 && !canGoNext -> delta * SWIPE_EDGE_RESISTANCE
else -> delta
}
swipeOffsetX = (swipeOffsetX + resisted).coerceIn(-coverWidthPx, coverWidthPx)
},
onDragStarted = {
dragging = true
settleJob?.cancel()
},
onDragStopped = { velocity -> onDragStopped = { velocity ->
val threshold = coverWidthPx * SWIPE_THRESHOLD_FRACTION dragging = false
when { when (swipeAction(swipeOffsetX, velocity, coverWidthPx, canGoNext, canGoPrevious)) {
swipeOffsetX <= -threshold || velocity <= -SWIPE_FLING_VELOCITY -> onNext() SwipeAction.CommitNext -> {
swipeOffsetX >= threshold || velocity >= SWIPE_FLING_VELOCITY -> onPrevious() commitSwipe(1)
}
SwipeAction.CommitPrevious -> {
commitSwipe(-1)
}
SwipeAction.Next -> {
onNext()
animateSwipe(0f)
}
SwipeAction.Previous -> {
onPrevious()
animateSwipe(0f)
}
SwipeAction.None -> {
animateSwipe(0f)
}
} }
settleSwipe()
}, },
).clickable( ).clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@@ -301,15 +440,30 @@ fun NowPlayingScreen(
.testTag(NOW_PLAYING_COVER_TAG), .testTag(NOW_PLAYING_COVER_TAG),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CoverArt( strip.forEachIndexed { index, slot ->
ref = coverArt, if (slot != null && (index == 1 || coverWidthPx > 0f)) {
name = state.item?.title.orEmpty(), CoverArt(
thumbnailRef = thumbnailRef, ref = slot.ref,
showPlaceholder = coverArt == null, name = slot.name,
square = false, thumbnailRef = slot.thumbnailRef,
elevation = 3.dp, showPlaceholder = slot.ref == null,
modifier = Modifier.fillMaxSize(), square = false,
) elevation = 3.dp,
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
translationX = (index - 1) * coverWidthPx + swipeOffsetX
}.then(
when (index) {
0 -> Modifier.testTag(NOW_PLAYING_PREVIOUS_TAG)
2 -> Modifier.testTag(NOW_PLAYING_NEXT_TAG)
else -> Modifier
},
),
)
}
}
} }
val density = LocalDensity.current val density = LocalDensity.current
val titleHeight = val titleHeight =
@@ -47,6 +47,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -167,6 +168,64 @@ class PlaybackControllerTest {
assertEquals("cover-al1", runBlocking { controller.upcomingItem()?.coverArtId }) assertEquals("cover-al1", runBlocking { controller.upcomingItem()?.coverArtId })
} }
@Test
fun previousItemIsThePreviousQueueItemWithItsCoverArt() {
seedAlbum(3, sourceId = 1)
runBlocking {
db.libraryDao().upsertAlbums(
listOf(
Album(
sourceId = 1,
id = "al1",
artistId = "ar1",
name = "Album",
albumArtist = "Artist",
created = 0,
coverArt = "cover-al1",
genre = null,
year = null,
starred = null,
songCount = 3,
),
),
)
}
controller.playAlbum(1, "al1", 1)
await {
controller.state.value.item
?.id == "s2"
}
assertEquals("s1", runBlocking { controller.previousItem()?.id })
assertEquals("cover-al1", runBlocking { controller.previousItem()?.coverArtId })
}
@Test
fun previousItemIsNullAtTheStartOfTheQueue() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
assertNull(runBlocking { controller.previousItem() })
}
@Test
fun previousItemIsNullOncePreviousWouldRestartTheTrack() {
seedAlbum(3, sourceId = 1)
controller.playAlbum(1, "al1", 1)
await {
controller.state.value.item
?.id == "s2"
}
handle.positionMs = 3_100
assertNull(runBlocking { controller.previousItem() })
}
@Test @Test
fun advancingShiftsTheWindowAndKeepsItBounded() { fun advancingShiftsTheWindowAndKeepsItBounded() {
seedAlbum(100, sourceId = 1) seedAlbum(100, sourceId = 1)
@@ -22,6 +22,7 @@ import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem import com.subtracks.playback.QueueItem
import com.subtracks.ui.theme.SubtracksTheme import com.subtracks.ui.theme.SubtracksTheme
import com.subtracks.ui.theme.artworkColorsFromSeed import com.subtracks.ui.theme.artworkColorsFromSeed
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -55,6 +56,8 @@ class NowPlayingTapsTest {
onArtistClick: (() -> Unit)? = null, onArtistClick: (() -> Unit)? = null,
onNext: () -> Unit = {}, onNext: () -> Unit = {},
onPrevious: () -> Unit = {}, onPrevious: () -> Unit = {},
previousArt: NowPlayingArt? = null,
nextArt: NowPlayingArt? = null,
) { ) {
composeRule.setContent { composeRule.setContent {
SubtracksTheme { SubtracksTheme {
@@ -72,11 +75,21 @@ class NowPlayingTapsTest {
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onSeek = {}, onSeek = {},
previousArt = previousArt,
nextArt = nextArt,
) )
} }
} }
} }
private fun adjacentArt(id: String) =
NowPlayingArt(
id = id,
ref = CoverArtRef(id, "test:$id"),
thumbnailRef = null,
name = id,
)
@Test @Test
fun tappingTheCoverArtOpensTheAlbum() { fun tappingTheCoverArtOpensTheAlbum() {
var opened = false var opened = false
@@ -158,6 +171,72 @@ class NowPlayingTapsTest {
assertTrue("a right swipe should not skip to the next track", !next) assertTrue("a right swipe should not skip to the next track", !next)
} }
@Test
fun theSwipeActionCommitsWhenANeighbourExists() {
assertEquals(
SwipeAction.CommitNext,
swipeAction(offsetX = -300f, velocity = 0f, width = 1000f, canGoNext = true, canGoPrevious = false),
)
assertEquals(
SwipeAction.CommitPrevious,
swipeAction(offsetX = 300f, velocity = 0f, width = 1000f, canGoNext = false, canGoPrevious = true),
)
}
@Test
fun theSwipeActionFallsBackWhenTheNeighbourIsMissing() {
assertEquals(
SwipeAction.Next,
swipeAction(offsetX = -300f, velocity = 0f, width = 1000f, canGoNext = false, canGoPrevious = false),
)
assertEquals(
SwipeAction.Previous,
swipeAction(offsetX = 300f, velocity = 0f, width = 1000f, canGoNext = false, canGoPrevious = false),
)
}
@Test
fun theSwipeActionIgnoresShortAndUnmeasuredSwipes() {
assertEquals(
SwipeAction.None,
swipeAction(offsetX = -50f, velocity = 0f, width = 1000f, canGoNext = true, canGoPrevious = true),
)
assertEquals(
SwipeAction.None,
swipeAction(offsetX = -300f, velocity = 0f, width = 0f, canGoNext = true, canGoPrevious = true),
)
}
@Test
fun rotatingTheStripMovesTheNeighbourIntoTheCentre() {
val a = adjacentArt("a")
val b = adjacentArt("b")
val c = adjacentArt("c")
val base = listOf(a, b, c)
assertEquals(listOf(b, c, null), rotateStrip(base, 1))
assertEquals(listOf(null, a, b), rotateStrip(base, -1))
}
@Test
fun theAdjacentArtRendersBesideTheCurrentArt() {
render(
previousArt = adjacentArt("s-previous"),
nextArt = adjacentArt("s-next"),
)
composeRule.onNodeWithTag(NOW_PLAYING_PREVIOUS_TAG, useUnmergedTree = true).assertExists()
composeRule.onNodeWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true).assertExists()
}
@Test
fun theAdjacentArtIsAbsentWithoutNeighbours() {
render()
composeRule.onNodeWithTag(NOW_PLAYING_PREVIOUS_TAG, useUnmergedTree = true).assertDoesNotExist()
composeRule.onNodeWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true).assertDoesNotExist()
}
@Test @Test
fun aShortSlowDragOnTheCoverDoesNotSkip() { fun aShortSlowDragOnTheCoverDoesNotSkip() {
var next = false var next = false