mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
keep the previous artwork visible and stop the neighbour flashing mid-swipe
Swipe-back now goes to the previous track (previousTrack) instead of restarting, so the previous peek stays visible past the restart threshold. The frozen strip only hands back once the live neighbour props match it, so a stale neighbour can no longer flash while the slide settles.
This commit is contained in:
@@ -25,8 +25,6 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
internal const val PLAYBACK_RESTART_THRESHOLD_MS = 3_000L
|
|
||||||
|
|
||||||
data class QueueItem(
|
data class QueueItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -747,7 +745,7 @@ class PlaybackController(
|
|||||||
|
|
||||||
fun previous() {
|
fun previous() {
|
||||||
val player = player ?: return
|
val player = player ?: return
|
||||||
if (player.currentPositionMs > PLAYBACK_RESTART_THRESHOLD_MS) {
|
if (player.currentPositionMs > RESTART_THRESHOLD_MS) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
startLock.withLock { player.seekTo(0) }
|
startLock.withLock { player.seekTo(0) }
|
||||||
refresh()
|
refresh()
|
||||||
@@ -788,6 +786,26 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun previousTrack() {
|
||||||
|
val snapshot = snapshot ?: return
|
||||||
|
if (snapshot.size == 0L) return
|
||||||
|
val current = currentPosition() ?: return
|
||||||
|
if (offline) {
|
||||||
|
scope.launch {
|
||||||
|
downloads.awaitLoaded()
|
||||||
|
val target =
|
||||||
|
downloadedAtOrBefore(snapshot, current - 1)
|
||||||
|
?: if (repeatMode == RepeatMode.All) downloadedAtOrBefore(snapshot, snapshot.size - 1) else null
|
||||||
|
target?.let { jumpTo(it) }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
current > 0 -> jumpTo(current - 1)
|
||||||
|
repeatMode == RepeatMode.All -> jumpTo(snapshot.size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun addToQueue(
|
fun addToQueue(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
kind: QueueKind,
|
kind: QueueKind,
|
||||||
@@ -906,15 +924,13 @@ class PlaybackController(
|
|||||||
suspend fun previousItem(): QueueItem? {
|
suspend fun previousItem(): QueueItem? {
|
||||||
var current: QueueSnapshot? = null
|
var current: QueueSnapshot? = null
|
||||||
var position: Long? = null
|
var position: Long? = null
|
||||||
var restarting = false
|
|
||||||
startLock.withLock {
|
startLock.withLock {
|
||||||
current = snapshot
|
current = snapshot
|
||||||
position = currentPosition()
|
position = currentPosition()
|
||||||
restarting = (player?.currentPositionMs ?: 0L) > PLAYBACK_RESTART_THRESHOLD_MS
|
|
||||||
}
|
}
|
||||||
val snapshot = current ?: return null
|
val snapshot = current ?: return null
|
||||||
val at = position ?: return null
|
val at = position ?: return null
|
||||||
if (at <= 0 || restarting) return null
|
if (at <= 0) return null
|
||||||
return queueRepository.itemAt(snapshot, at - 1)?.toQueueItem()
|
return queueRepository.itemAt(snapshot, at - 1)?.toQueueItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1513,6 +1529,7 @@ class PlaybackController(
|
|||||||
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
||||||
const val QUEUE_WINDOW_RADIUS = 25L
|
const val QUEUE_WINDOW_RADIUS = 25L
|
||||||
const val WINDOW_SHIFT_DELAY_MS = 400L
|
const val WINDOW_SHIFT_DELAY_MS = 400L
|
||||||
|
const val RESTART_THRESHOLD_MS = 3_000L
|
||||||
const val SEED_PIN_TRIES = 1_000_000
|
const val SEED_PIN_TRIES = 1_000_000
|
||||||
const val FILE_SCHEME = "file:"
|
const val FILE_SCHEME = "file:"
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ import com.subtracks.data.model.Song
|
|||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PLAYBACK_RESTART_THRESHOLD_MS
|
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.playback.PlaybackState
|
import com.subtracks.playback.PlaybackState
|
||||||
import com.subtracks.playback.QueueItem
|
import com.subtracks.playback.QueueItem
|
||||||
@@ -153,6 +152,21 @@ internal fun rotateStrip(
|
|||||||
listOf(null, base.getOrNull(0), base.getOrNull(1))
|
listOf(null, base.getOrNull(0), base.getOrNull(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun handBackReady(
|
||||||
|
frozen: List<NowPlayingArt?>,
|
||||||
|
itemId: String?,
|
||||||
|
previousArt: NowPlayingArt?,
|
||||||
|
nextArt: NowPlayingArt?,
|
||||||
|
): Boolean {
|
||||||
|
val centre = frozen.getOrNull(1)?.id ?: return false
|
||||||
|
if (centre != itemId) return false
|
||||||
|
return when {
|
||||||
|
frozen.getOrNull(2) == null -> previousArt?.id == frozen.getOrNull(0)?.id
|
||||||
|
frozen.getOrNull(0) == null -> nextArt?.id == frozen.getOrNull(2)?.id
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NowPlayingRoute(
|
fun NowPlayingRoute(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -211,8 +225,7 @@ fun NowPlayingRoute(
|
|||||||
name = item.title,
|
name = item.title,
|
||||||
)
|
)
|
||||||
var adjacent by remember { mutableStateOf(AdjacentArt()) }
|
var adjacent by remember { mutableStateOf(AdjacentArt()) }
|
||||||
val previousReachable = positionMs <= PLAYBACK_RESTART_THRESHOLD_MS
|
LaunchedEffect(state.item?.id, state.layout) {
|
||||||
LaunchedEffect(state.item?.id, state.layout, previousReachable) {
|
|
||||||
if (state.item == null) {
|
if (state.item == null) {
|
||||||
adjacent = AdjacentArt()
|
adjacent = AdjacentArt()
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
@@ -230,8 +243,9 @@ fun NowPlayingRoute(
|
|||||||
title = sourceTitle.value,
|
title = sourceTitle.value,
|
||||||
coverArt = art,
|
coverArt = art,
|
||||||
thumbnailRef = thumbnail,
|
thumbnailRef = thumbnail,
|
||||||
previousArt = if (previousReachable) adjacent.previous else null,
|
previousArt = adjacent.previous,
|
||||||
nextArt = adjacent.next,
|
nextArt = adjacent.next,
|
||||||
|
onSkipPrevious = controller::previousTrack,
|
||||||
artwork = rememberArtworkColors(thumbnail ?: art, fallbackName = state.item?.title),
|
artwork = rememberArtworkColors(thumbnail ?: art, fallbackName = state.item?.title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onQueue = onQueue,
|
onQueue = onQueue,
|
||||||
@@ -263,6 +277,7 @@ fun NowPlayingScreen(
|
|||||||
onPlayPause: () -> Unit,
|
onPlayPause: () -> Unit,
|
||||||
onNext: () -> Unit,
|
onNext: () -> Unit,
|
||||||
onPrevious: () -> Unit,
|
onPrevious: () -> Unit,
|
||||||
|
onSkipPrevious: () -> Unit = onPrevious,
|
||||||
onShuffle: () -> Unit = {},
|
onShuffle: () -> Unit = {},
|
||||||
onRepeat: () -> Unit = {},
|
onRepeat: () -> Unit = {},
|
||||||
onMore: () -> Unit = {},
|
onMore: () -> Unit = {},
|
||||||
@@ -317,18 +332,19 @@ fun NowPlayingScreen(
|
|||||||
fun commitSwipe(direction: Int) {
|
fun commitSwipe(direction: Int) {
|
||||||
frozenStrip = rotateStrip(strip, direction)
|
frozenStrip = rotateStrip(strip, direction)
|
||||||
swipeOffsetX += direction * coverWidthPx
|
swipeOffsetX += direction * coverWidthPx
|
||||||
if (direction > 0) onNext() else onPrevious()
|
if (direction > 0) onNext() else onSkipPrevious()
|
||||||
animateSwipe(0f)
|
animateSwipe(0f)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The rotated strip keeps the slide continuous while the controller catches up. Hand back once
|
// The rotated strip keeps the slide continuous while the controller catches up. It only hands
|
||||||
// it reports the target track, or after a grace period when the action did not move there (a
|
// back once the live props agree with the frozen slots, so a stale neighbour can never flash
|
||||||
// restart, an offline skip), so a frozen strip can never stay desynced from playback.
|
// during the slide; the timeout covers an action that does not move (an offline skip).
|
||||||
LaunchedEffect(state.item?.id, frozenStrip, dragging) {
|
LaunchedEffect(state.item?.id, frozenStrip, dragging, previousArt, nextArt) {
|
||||||
val pending = frozenStrip ?: return@LaunchedEffect
|
val pending = frozenStrip ?: return@LaunchedEffect
|
||||||
if (pending.getOrNull(1)?.id == state.item?.id) {
|
if (dragging) return@LaunchedEffect
|
||||||
|
if (handBackReady(pending, state.item?.id, previousArt, nextArt)) {
|
||||||
frozenStrip = null
|
frozenStrip = null
|
||||||
} else if (!dragging) {
|
} else {
|
||||||
delay(HAND_BACK_TIMEOUT_MS)
|
delay(HAND_BACK_TIMEOUT_MS)
|
||||||
if (frozenStrip === pending) frozenStrip = null
|
if (frozenStrip === pending) frozenStrip = null
|
||||||
}
|
}
|
||||||
@@ -422,7 +438,7 @@ fun NowPlayingScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
SwipeAction.Previous -> {
|
SwipeAction.Previous -> {
|
||||||
onPrevious()
|
onSkipPrevious()
|
||||||
animateSwipe(0f)
|
animateSwipe(0f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ class PlaybackControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun previousItemIsNullOncePreviousWouldRestartTheTrack() {
|
fun previousTrackMovesBackEvenOncePreviousWouldRestart() {
|
||||||
seedAlbum(3, sourceId = 1)
|
seedAlbum(3, sourceId = 1)
|
||||||
controller.playAlbum(1, "al1", 1)
|
controller.playAlbum(1, "al1", 1)
|
||||||
await {
|
await {
|
||||||
@@ -222,8 +222,12 @@ class PlaybackControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handle.positionMs = 3_100
|
handle.positionMs = 3_100
|
||||||
|
controller.previousTrack()
|
||||||
|
|
||||||
assertNull(runBlocking { controller.previousItem() })
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class NowPlayingTapsTest {
|
|||||||
onArtistClick: (() -> Unit)? = null,
|
onArtistClick: (() -> Unit)? = null,
|
||||||
onNext: () -> Unit = {},
|
onNext: () -> Unit = {},
|
||||||
onPrevious: () -> Unit = {},
|
onPrevious: () -> Unit = {},
|
||||||
|
onSkipPrevious: () -> Unit = onPrevious,
|
||||||
previousArt: NowPlayingArt? = null,
|
previousArt: NowPlayingArt? = null,
|
||||||
nextArt: NowPlayingArt? = null,
|
nextArt: NowPlayingArt? = null,
|
||||||
) {
|
) {
|
||||||
@@ -72,6 +73,7 @@ class NowPlayingTapsTest {
|
|||||||
onPlayPause = {},
|
onPlayPause = {},
|
||||||
onNext = onNext,
|
onNext = onNext,
|
||||||
onPrevious = onPrevious,
|
onPrevious = onPrevious,
|
||||||
|
onSkipPrevious = onSkipPrevious,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onSeek = {},
|
onSeek = {},
|
||||||
@@ -163,12 +165,34 @@ class NowPlayingTapsTest {
|
|||||||
fun swipingRightOnTheCoverGoesToThePreviousTrack() {
|
fun swipingRightOnTheCoverGoesToThePreviousTrack() {
|
||||||
var next = false
|
var next = false
|
||||||
var previous = false
|
var previous = false
|
||||||
render(onNext = { next = true }, onPrevious = { previous = true })
|
var skipPrevious = false
|
||||||
|
render(
|
||||||
|
onNext = { next = true },
|
||||||
|
onPrevious = { previous = true },
|
||||||
|
onSkipPrevious = { skipPrevious = true },
|
||||||
|
)
|
||||||
|
|
||||||
composeRule.onNodeWithTag(NOW_PLAYING_COVER_TAG).performTouchInput { swipeRight() }
|
composeRule.onNodeWithTag(NOW_PLAYING_COVER_TAG).performTouchInput { swipeRight() }
|
||||||
|
|
||||||
assertTrue("a right swipe should go to the previous track", previous)
|
assertTrue("a right swipe should skip to the previous track", skipPrevious)
|
||||||
assertTrue("a right swipe should not skip to the next track", !next)
|
assertTrue("a right swipe should not skip to the next track", !next)
|
||||||
|
assertTrue("a right swipe should not use the restart-on-previous button", !previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theStripHandsBackOnlyWhenTheLivePropsAgree() {
|
||||||
|
val before = adjacentArt("s0")
|
||||||
|
val current = adjacentArt("s1")
|
||||||
|
val next = adjacentArt("s2")
|
||||||
|
val after = adjacentArt("s3")
|
||||||
|
val frozenNext = listOf(current, next, null)
|
||||||
|
val frozenPrevious = listOf(null, before, current)
|
||||||
|
|
||||||
|
assertTrue(!handBackReady(frozenNext, itemId = "s1", previousArt = before, nextArt = after))
|
||||||
|
assertTrue(!handBackReady(frozenNext, itemId = "s2", previousArt = before, nextArt = after))
|
||||||
|
assertTrue(handBackReady(frozenNext, itemId = "s2", previousArt = current, nextArt = after))
|
||||||
|
assertTrue(handBackReady(frozenPrevious, itemId = "s0", previousArt = null, nextArt = current))
|
||||||
|
assertTrue(!handBackReady(frozenPrevious, itemId = "s1", previousArt = null, nextArt = current))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user