animate queue edits and focus the playing track on open

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent a682cfc1cb
commit bb42975bb4
2 changed files with 95 additions and 33 deletions
@@ -4,7 +4,6 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -44,6 +43,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.SongListItem
import com.subtracks.data.repo.QUEUE_CHUNK import com.subtracks.data.repo.QUEUE_CHUNK
import com.subtracks.data.repo.QueueRepository import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.QueueWindowItem import com.subtracks.data.repo.QueueWindowItem
@@ -62,20 +62,29 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
private const val LOAD_THRESHOLD = 5 private const val LOAD_THRESHOLD = 5
data class QueueRow(
val id: Long,
val position: Long,
val song: SongListItem,
)
class QueueViewModel( class QueueViewModel(
private val queueRepository: QueueRepository, private val queueRepository: QueueRepository,
private val playbackController: PlaybackController, private val playbackController: PlaybackController,
) : ViewModel() { ) : ViewModel() {
val rows = mutableStateListOf<QueueWindowItem>() val rows = mutableStateListOf<QueueRow>()
var ready by mutableStateOf(false) var ready by mutableStateOf(false)
private set private set
private var nextId = 0L
private val mutex = Mutex() private val mutex = Mutex()
private var size = 0L private var size = 0L
private var first = 0L private var first = 0L
private var last = -1L private var last = -1L
private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item)
fun open() { fun open() {
viewModelScope.launch { viewModelScope.launch {
mutex.withLock { mutex.withLock {
@@ -88,10 +97,9 @@ class QueueViewModel(
val cursor = (playbackController.state.value.position ?: 0L).coerceIn(0, size - 1) val cursor = (playbackController.state.value.position ?: 0L).coerceIn(0, size - 1)
val start = (cursor - QUEUE_CHUNK / 2).coerceAtLeast(0) val start = (cursor - QUEUE_CHUNK / 2).coerceAtLeast(0)
val end = (start + QUEUE_CHUNK - 1).coerceAtMost(size - 1) val end = (start + QUEUE_CHUNK - 1).coerceAtMost(size - 1)
val loaded = queueRepository.range(snapshot, start, end) rows.addAll(queueRepository.range(snapshot, start, end).map(::newRow))
rows.addAll(loaded) first = rows.first().position
first = loaded.firstOrNull()?.position ?: 0 last = rows.last().position
last = loaded.lastOrNull()?.position ?: -1
} }
ready = true ready = true
} }
@@ -108,8 +116,8 @@ class QueueViewModel(
val from = (first - QUEUE_CHUNK).coerceAtLeast(0) val from = (first - QUEUE_CHUNK).coerceAtLeast(0)
val loaded = queueRepository.range(snapshot, from, first - 1) val loaded = queueRepository.range(snapshot, from, first - 1)
if (loaded.isEmpty()) return@withLock if (loaded.isEmpty()) return@withLock
rows.addAll(0, loaded) rows.addAll(0, loaded.map(::newRow))
first = loaded.first().position first = rows.first().position
} }
} }
} }
@@ -125,8 +133,8 @@ class QueueViewModel(
val to = (last + QUEUE_CHUNK).coerceAtMost(size - 1) val to = (last + QUEUE_CHUNK).coerceAtMost(size - 1)
val loaded = queueRepository.range(snapshot, last + 1, to) val loaded = queueRepository.range(snapshot, last + 1, to)
if (loaded.isEmpty()) return@withLock if (loaded.isEmpty()) return@withLock
rows.addAll(loaded) rows.addAll(loaded.map(::newRow))
last = loaded.last().position last = rows.last().position
} }
} }
} }
@@ -151,25 +159,59 @@ class QueueViewModel(
if (from == to) return if (from == to) return
viewModelScope.launch { viewModelScope.launch {
playbackController.move(from, to) playbackController.move(from, to)
mutex.withLock { refresh() } mutex.withLock {
// The loaded order already reflects the move, so only the positions need updating.
for (i in rows.indices) {
val row = rows[i]
val position = first + i
if (row.position != position) rows[i] = row.copy(position = position)
}
}
} }
} }
fun remove(position: Long) { fun remove(position: Long) {
viewModelScope.launch { viewModelScope.launch {
playbackController.removeAt(position) playbackController.removeAt(position)
mutex.withLock { refresh() } mutex.withLock { removeRow(position) }
} }
} }
fun undo() { fun undo() {
viewModelScope.launch { viewModelScope.launch {
playbackController.undo() playbackController.undo()
mutex.withLock { refresh() } mutex.withLock { reload() }
} }
} }
private suspend fun refresh() { private suspend fun removeRow(position: Long) {
val index = rows.indexOfFirst { it.position == position }
if (index < 0) {
reload()
return
}
rows.removeAt(index)
for (i in index until rows.size) {
val row = rows[i]
rows[i] = row.copy(position = row.position - 1)
}
val snapshot = queueRepository.snapshot()
size = snapshot.size
if (rows.isEmpty()) {
first = 0
last = -1
return
}
first = rows.first().position
last = rows.last().position
val next = last + 1
if (next <= size - 1) {
queueRepository.range(snapshot, next, next).firstOrNull()?.let { rows.add(newRow(it)) }
last = rows.last().position
}
}
private suspend fun reload() {
val snapshot = queueRepository.snapshot() val snapshot = queueRepository.snapshot()
size = snapshot.size size = snapshot.size
if (size == 0L) { if (size == 0L) {
@@ -178,13 +220,20 @@ class QueueViewModel(
last = -1 last = -1
return return
} }
val start = first.coerceIn(0, size - 1) val start = (rows.firstOrNull()?.position ?: 0L).coerceIn(0, size - 1)
val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(size - 1) val end = (start + rows.size.coerceAtLeast(QUEUE_CHUNK) - 1).coerceAtMost(size - 1)
val loaded = queueRepository.range(snapshot, start, end) val loaded = queueRepository.range(snapshot, start, end)
val ids = rows.groupBy { it.song.song.id }.mapValues { entry -> entry.value.map { it.id }.toMutableList() }
val updated =
loaded.map { item ->
val reused = ids[item.item.song.id]
val id = if (!reused.isNullOrEmpty()) reused.removeAt(0) else nextId++
QueueRow(id, item.position, item.item)
}
rows.clear() rows.clear()
rows.addAll(loaded) rows.addAll(updated)
first = loaded.firstOrNull()?.position ?: start first = rows.first().position
last = loaded.lastOrNull()?.position ?: -1 last = rows.last().position
} }
} }
@@ -218,7 +267,7 @@ fun QueueRoute(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun QueueScreen( fun QueueScreen(
rows: List<QueueWindowItem>, rows: List<QueueRow>,
ready: Boolean, ready: Boolean,
currentPosition: Long?, currentPosition: Long?,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
@@ -237,12 +286,25 @@ fun QueueScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var dragFrom by remember { mutableStateOf<Long?>(null) } var dragFrom by remember { mutableStateOf<Long?>(null) }
var dragTo by remember { mutableStateOf<Long?>(null) } var dragTo by remember { mutableStateOf<Long?>(null) }
var centered by remember { mutableStateOf(false) }
LaunchedEffect(ready, rows.size, currentPosition) {
if (centered || !ready || rows.isEmpty()) return@LaunchedEffect
val position = currentPosition ?: return@LaunchedEffect
val index = rows.indexOfFirst { it.position == position }
if (index >= 0) {
listState.scrollToItem(index)
centered = true
}
}
val reorderState = val reorderState =
rememberReorderableLazyListState(listState) { from, to -> rememberReorderableLazyListState(listState) { from, to ->
val fromPosition = rows.getOrNull(from.index)?.position
val toPosition = rows.getOrNull(to.index)?.position
onReorder(from.index, to.index) onReorder(from.index, to.index)
dragFrom = from.key as? Long dragFrom = fromPosition
dragTo = to.key as? Long dragTo = toPosition
} }
LaunchedEffect(listState) { LaunchedEffect(listState) {
@@ -294,20 +356,20 @@ fun QueueScreen(
modifier = Modifier.padding(padding).fillMaxSize(), modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp), contentPadding = PaddingValues(bottom = 16.dp),
) { ) {
items(count = rows.size, key = { rows[it].position }) { index -> items(count = rows.size, key = { rows[it].id }) { index ->
val row = rows[index] val row = rows[index]
ReorderableItem( ReorderableItem(
state = reorderState, state = reorderState,
key = row.position, key = row.id,
animateItemModifier = animateItemModifier =
Modifier.animateItem( Modifier.animateItem(
fadeInSpec = null, fadeInSpec = tween(150),
fadeOutSpec = null, fadeOutSpec = tween(150),
placementSpec = tween(200), placementSpec = tween(200),
), ),
) { isDragging -> ) { isDragging ->
QueueRow( QueueRowItem(
item = row, row = row,
isPlaying = row.position == currentPosition, isPlaying = row.position == currentPosition,
floating = isDragging, floating = isDragging,
coverArt = coverArt, coverArt = coverArt,
@@ -341,8 +403,8 @@ fun QueueScreen(
} }
@Composable @Composable
private fun QueueRow( private fun QueueRowItem(
item: QueueWindowItem, row: QueueRow,
isPlaying: Boolean, isPlaying: Boolean,
floating: Boolean, floating: Boolean,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
@@ -351,8 +413,8 @@ private fun QueueRow(
onRemove: () -> Unit, onRemove: () -> Unit,
) { ) {
SongRow( SongRow(
song = item.item.song, song = row.song.song,
coverArtId = item.item.coverArt, coverArtId = row.song.coverArt,
coverArt = coverArt, coverArt = coverArt,
isPlaying = isPlaying, isPlaying = isPlaying,
trailingContent = { trailingContent = {
@@ -34,7 +34,6 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.repo.QueueWindowItem
import com.subtracks.playback.PlaybackState import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem import com.subtracks.playback.QueueItem
import com.subtracks.ui.library.ALBUM_COVER_TAG import com.subtracks.ui.library.ALBUM_COVER_TAG
@@ -44,6 +43,7 @@ import com.subtracks.ui.library.LibraryTab
import com.subtracks.ui.library.PlaylistDetailScreen import com.subtracks.ui.library.PlaylistDetailScreen
import com.subtracks.ui.playback.MiniPlayer import com.subtracks.ui.playback.MiniPlayer
import com.subtracks.ui.playback.NowPlayingScreen import com.subtracks.ui.playback.NowPlayingScreen
import com.subtracks.ui.playback.QueueRow
import com.subtracks.ui.playback.QueueScreen import com.subtracks.ui.playback.QueueScreen
import com.subtracks.ui.settings.AddSourceScreen import com.subtracks.ui.settings.AddSourceScreen
import com.subtracks.ui.settings.AddSourceState import com.subtracks.ui.settings.AddSourceState
@@ -224,7 +224,7 @@ class AppScreenshotTest {
SubtracksTheme { SubtracksTheme {
QueueScreen( QueueScreen(
rows = rows =
Fixtures.songItems.mapIndexed { index, item -> QueueWindowItem(index.toLong(), item) }, Fixtures.songItems.mapIndexed { index, item -> QueueRow(index.toLong(), index.toLong(), item) },
ready = true, ready = true,
currentPosition = 1, currentPosition = 1,
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },