mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user