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 kotlin.random.Random
internal const val PLAYBACK_RESTART_THRESHOLD_MS = 3_000L
data class QueueItem(
val id: String,
val title: String,
@@ -745,7 +747,7 @@ class PlaybackController(
fun previous() {
val player = player ?: return
if (player.currentPositionMs > RESTART_THRESHOLD_MS) {
if (player.currentPositionMs > PLAYBACK_RESTART_THRESHOLD_MS) {
scope.launch {
startLock.withLock { player.seekTo(0) }
refresh()
@@ -901,6 +903,21 @@ class PlaybackController(
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(
entries: List<QueueEntry>,
startPosition: Long,
@@ -1496,7 +1513,6 @@ class PlaybackController(
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
const val QUEUE_WINDOW_RADIUS = 25L
const val WINDOW_SHIFT_DELAY_MS = 400L
const val RESTART_THRESHOLD_MS = 3_000L
const val SEED_PIN_TRIES = 1_000_000
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.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.SingletonImageLoader
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.crossfade
import coil3.size.Dimension
import com.subtracks.data.model.CoverArtRef
import com.subtracks.ui.theme.artworkColorsFromSeed
import com.subtracks.ui.theme.placeholderSeed
import kotlinx.coroutines.CancellationException
import java.lang.Character.UnicodeScript
private const val MAX_CACHED_RATIOS = 256
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 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
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 {
if (isEmpty()) return false
return when (UnicodeScript.of(codePointAt(0))) {
@@ -57,6 +57,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
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.LibraryRepository
import com.subtracks.data.source.StarType
import com.subtracks.playback.PLAYBACK_RESTART_THRESHOLD_MS
import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.playback.RepeatMode
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.prefetchArtworkRatio
import com.subtracks.ui.theme.ArtworkColors
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.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.compose.koinInject
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_FLING_VELOCITY = 1000f
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
fun NowPlayingRoute(
@@ -147,10 +202,27 @@ fun NowPlayingRoute(
LaunchedEffect(queueContext) {
sourceTitle.value = controller.sourceTitle(queueContext)?.takeIf { it.isNotBlank() } ?: fallbackTitle
}
LaunchedEffect(state.item?.id, state.hasNext) {
if (!state.hasNext) return@LaunchedEffect
val next = controller.upcomingItem() ?: return@LaunchedEffect
controller.coverArt(next, thumbnail = true)?.let { ArtworkSeedCache.prefetch(context, it) }
fun artFor(item: QueueItem) =
NowPlayingArt(
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(
state = state,
@@ -158,6 +230,8 @@ fun NowPlayingRoute(
title = sourceTitle.value,
coverArt = art,
thumbnailRef = thumbnail,
previousArt = if (previousReachable) adjacent.previous else null,
nextArt = adjacent.next,
artwork = rememberArtworkColors(thumbnail ?: art, fallbackName = state.item?.title),
onBack = onBack,
onQueue = onQueue,
@@ -198,6 +272,8 @@ fun NowPlayingScreen(
onToggleStar: (() -> Unit)? = null,
onSeek: (Long) -> Unit,
thumbnailRef: CoverArtRef? = null,
previousArt: NowPlayingArt? = null,
nextArt: NowPlayingArt? = null,
modifier: Modifier = Modifier,
) {
val playButtonSize = 90.dp
@@ -212,19 +288,52 @@ fun NowPlayingScreen(
platformStyle = PlatformTextStyle(includeFontPadding = false),
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 coverWidthPx by remember { mutableFloatStateOf(0f) }
var settleJob by remember { mutableStateOf<Job?>(null) }
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 =
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) {
ArtworkTheme(artwork) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
@@ -278,19 +387,49 @@ fun NowPlayingScreen(
.weight(1f)
.fillMaxWidth()
.padding(top = 2.dp)
.clipToBounds()
.onSizeChanged { coverWidthPx = it.width.toFloat() }
.graphicsLayer { translationX = swipeOffsetX }
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta -> swipeOffsetX += delta },
onDragStarted = { settleJob?.cancel() },
state =
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 ->
val threshold = coverWidthPx * SWIPE_THRESHOLD_FRACTION
when {
swipeOffsetX <= -threshold || velocity <= -SWIPE_FLING_VELOCITY -> onNext()
swipeOffsetX >= threshold || velocity >= SWIPE_FLING_VELOCITY -> onPrevious()
dragging = false
when (swipeAction(swipeOffsetX, velocity, coverWidthPx, canGoNext, canGoPrevious)) {
SwipeAction.CommitNext -> {
commitSwipe(1)
}
SwipeAction.CommitPrevious -> {
commitSwipe(-1)
}
SwipeAction.Next -> {
onNext()
animateSwipe(0f)
}
SwipeAction.Previous -> {
onPrevious()
animateSwipe(0f)
}
SwipeAction.None -> {
animateSwipe(0f)
}
}
settleSwipe()
},
).clickable(
interactionSource = remember { MutableInteractionSource() },
@@ -301,15 +440,30 @@ fun NowPlayingScreen(
.testTag(NOW_PLAYING_COVER_TAG),
contentAlignment = Alignment.Center,
) {
CoverArt(
ref = coverArt,
name = state.item?.title.orEmpty(),
thumbnailRef = thumbnailRef,
showPlaceholder = coverArt == null,
square = false,
elevation = 3.dp,
modifier = Modifier.fillMaxSize(),
)
strip.forEachIndexed { index, slot ->
if (slot != null && (index == 1 || coverWidthPx > 0f)) {
CoverArt(
ref = slot.ref,
name = slot.name,
thumbnailRef = slot.thumbnailRef,
showPlaceholder = slot.ref == null,
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 titleHeight =
@@ -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