align the swipe peek with the skip and harden its edges

Resolve previousItem/upcomingItem through the same offline download scan the transport uses, so the peeking art is the track the swipe will actually play. Clear a frozen strip when a fallback swipe fires, prefer the dragged direction over an opposite fling, and simplify the hand-back and ratio prefetch.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent 73add672e1
commit 88cd3764bf
5 changed files with 92 additions and 16 deletions
@@ -912,26 +912,39 @@ class PlaybackController(
suspend fun upcomingItem(): QueueItem? {
var current: QueueSnapshot? = null
var position: Long? = null
var isOffline = false
startLock.withLock {
current = snapshot
position = currentPosition()
isOffline = offline
}
val snapshot = current ?: return null
val at = position ?: return null
return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem()
if (!isOffline) return queueRepository.itemAt(snapshot, at + 1)?.toQueueItem()
downloads.awaitLoaded()
val target =
downloadedAtOrAfter(snapshot, at + 1)
?: if (repeatMode != RepeatMode.Off) downloadedAtOrAfter(snapshot, 0L) else null
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
}
suspend fun previousItem(): QueueItem? {
var current: QueueSnapshot? = null
var position: Long? = null
var isOffline = false
startLock.withLock {
current = snapshot
position = currentPosition()
isOffline = offline
}
val snapshot = current ?: return null
val at = position ?: return null
if (at <= 0) return null
return queueRepository.itemAt(snapshot, at - 1)?.toQueueItem()
if (!isOffline) return if (at <= 0) null else queueRepository.itemAt(snapshot, at - 1)?.toQueueItem()
downloads.awaitLoaded()
val target =
downloadedAtOrBefore(snapshot, at - 1)
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
return target?.let { queueRepository.itemAt(snapshot, it)?.toQueueItem() }
}
private fun play(
@@ -256,12 +256,13 @@ internal suspend fun prefetchArtworkRatio(
) {
if (ArtworkRatioCache.get(ref.cacheKey) != null) return
val request =
ImageRequest
.Builder(context)
.data(ref.url)
.diskCacheKey(ref.cacheKey)
.size(COVER_RATIO_PREFETCH_PX)
.build()
imageRequest(
context,
ref,
crossfade = false,
width = COVER_RATIO_PREFETCH_PX,
height = COVER_RATIO_PREFETCH_PX,
)
val image =
try {
(SingletonImageLoader.get(context).execute(request) as? SuccessResult)?.image
@@ -131,9 +131,15 @@ internal fun swipeAction(
): 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
val pastNext = offsetX <= -threshold
val pastPrevious = offsetX >= threshold
val wantsNext = pastNext || velocity <= -SWIPE_FLING_VELOCITY
val wantsPrevious = pastPrevious || velocity >= SWIPE_FLING_VELOCITY
return when {
pastNext && canGoNext -> SwipeAction.CommitNext
pastPrevious && canGoPrevious -> SwipeAction.CommitPrevious
pastNext -> SwipeAction.Next
pastPrevious -> SwipeAction.Previous
wantsNext && canGoNext -> SwipeAction.CommitNext
wantsPrevious && canGoPrevious -> SwipeAction.CommitPrevious
wantsNext -> SwipeAction.Next
@@ -346,7 +352,7 @@ fun NowPlayingScreen(
frozenStrip = null
} else {
delay(HAND_BACK_TIMEOUT_MS)
if (frozenStrip === pending) frozenStrip = null
frozenStrip = null
}
}
@@ -433,11 +439,13 @@ fun NowPlayingScreen(
}
SwipeAction.Next -> {
frozenStrip = null
onNext()
animateSwipe(0f)
}
SwipeAction.Previous -> {
frozenStrip = null
onSkipPrevious()
animateSwipe(0f)
}
@@ -545,17 +553,17 @@ fun NowPlayingScreen(
)
}
}
var dragging by remember { mutableStateOf(false) }
var sliderDragging by remember { mutableStateOf(false) }
var dragPosition by remember { mutableFloatStateOf(0f) }
Slider(
value = if (dragging) dragPosition else positionMs.toFloat(),
value = if (sliderDragging) dragPosition else positionMs.toFloat(),
onValueChange = {
dragging = true
sliderDragging = true
dragPosition = it
},
onValueChangeFinished = {
onSeek(dragPosition.toLong())
dragging = false
sliderDragging = false
},
valueRange = 0f..state.durationMs.toFloat().coerceAtLeast(1f),
enabled = state.durationMs > 0,
@@ -230,6 +230,36 @@ class PlaybackControllerTest {
}
}
@Test
fun offlinePreviousItemMatchesTheTrackPreviousSkipsTo() {
seedAlbum(5, sourceId = 1)
markDownloaded(1, "s1")
markDownloaded(1, "s4")
setOffline()
controller.playAlbum(1, "al1", 3)
await {
controller.state.value.item
?.id == "s4"
}
assertEquals("s1", runBlocking { controller.previousItem()?.id })
}
@Test
fun offlineUpcomingItemMatchesTheTrackNextPlays() {
seedAlbum(4, sourceId = 1)
markDownloaded(1, "s1")
markDownloaded(1, "s3")
setOffline()
controller.playAlbum(1, "al1", 0)
await {
controller.state.value.item
?.id == "s1"
}
assertEquals("s3", runBlocking { controller.upcomingItem()?.id })
}
@Test
fun advancingShiftsTheWindowAndKeepsItBounded() {
seedAlbum(100, sourceId = 1)
@@ -231,6 +231,30 @@ class NowPlayingTapsTest {
)
}
@Test
fun theSwipeActionPrefersTheDraggedDirectionOverAnOppositeFling() {
assertEquals(
SwipeAction.CommitPrevious,
swipeAction(offsetX = 300f, velocity = -2_000f, width = 1_000f, canGoNext = true, canGoPrevious = true),
)
assertEquals(
SwipeAction.CommitNext,
swipeAction(offsetX = -300f, velocity = 2_000f, width = 1_000f, canGoNext = true, canGoPrevious = true),
)
}
@Test
fun swipingLeftWithANeighbourRotatesTheStrip() {
render(nextArt = adjacentArt("s-next"))
composeRule.onNodeWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true).assertExists()
composeRule.onNodeWithTag(NOW_PLAYING_COVER_TAG).performTouchInput { swipeLeft() }
composeRule.waitForIdle()
composeRule.onNodeWithTag(NOW_PLAYING_NEXT_TAG, useUnmergedTree = true).assertDoesNotExist()
composeRule.onNodeWithTag(NOW_PLAYING_PREVIOUS_TAG, useUnmergedTree = true).assertExists()
}
@Test
fun rotatingTheStripMovesTheNeighbourIntoTheCentre() {
val a = adjacentArt("a")