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