From b4539b9ba55b8d219205a11f79c5f46f26704089 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 3 Oct 2026 06:19:44 +0000 Subject: [PATCH] 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. --- .../subtracks/playback/PlaybackController.kt | 20 +- .../com/subtracks/ui/components/Components.kt | 29 +++ .../subtracks/ui/playback/NowPlayingScreen.kt | 200 ++++++++++++++++-- .../playback/PlaybackControllerTest.kt | 59 ++++++ .../ui/playback/NowPlayingTapsTest.kt | 79 +++++++ 5 files changed, 362 insertions(+), 25 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 3849e39a..6dca6ad2 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -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, 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:" diff --git a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt index bc778ef2..77b5b3ef 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/Components.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/Components.kt @@ -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))) { diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index 1b8e96cf..da1ad155 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -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, + direction: Int, +): List = + 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?>(null) } + var dragging by remember { mutableStateOf(false) } var swipeOffsetX by remember { mutableFloatStateOf(0f) } var coverWidthPx by remember { mutableFloatStateOf(0f) } var settleJob by remember { mutableStateOf(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 = diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 9f3decdf..f60248ea 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -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) diff --git a/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingTapsTest.kt b/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingTapsTest.kt index b6c7395a..2365ce99 100644 --- a/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingTapsTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/playback/NowPlayingTapsTest.kt @@ -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