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
@@ -47,6 +47,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -167,6 +168,64 @@ class PlaybackControllerTest {
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
fun advancingShiftsTheWindowAndKeepsItBounded() {
seedAlbum(100, sourceId = 1)
@@ -22,6 +22,7 @@ import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.ui.theme.SubtracksTheme
import com.subtracks.ui.theme.artworkColorsFromSeed
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -55,6 +56,8 @@ class NowPlayingTapsTest {
onArtistClick: (() -> Unit)? = null,
onNext: () -> Unit = {},
onPrevious: () -> Unit = {},
previousArt: NowPlayingArt? = null,
nextArt: NowPlayingArt? = null,
) {
composeRule.setContent {
SubtracksTheme {
@@ -72,11 +75,21 @@ class NowPlayingTapsTest {
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onSeek = {},
previousArt = previousArt,
nextArt = nextArt,
)
}
}
}
private fun adjacentArt(id: String) =
NowPlayingArt(
id = id,
ref = CoverArtRef(id, "test:$id"),
thumbnailRef = null,
name = id,
)
@Test
fun tappingTheCoverArtOpensTheAlbum() {
var opened = false
@@ -158,6 +171,72 @@ class NowPlayingTapsTest {
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
fun aShortSlowDragOnTheCoverDoesNotSkip() {
var next = false