mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
animate queue edits and focus the playing track on open
This commit is contained in:
@@ -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") } },
|
||||||
|
|||||||
Reference in New Issue
Block a user