mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
add a one-step undo and harden queue edits
This commit is contained in:
@@ -12,7 +12,7 @@ private const val ALBUM_SONGS_SQL =
|
|||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " +
|
"WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " +
|
||||||
"ORDER BY songs.disc, songs.track"
|
"ORDER BY songs.disc, songs.track, songs.id"
|
||||||
|
|
||||||
private const val SONG_SQL =
|
private const val SONG_SQL =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ const val QUEUE_PAGE_SIZE = 60
|
|||||||
|
|
||||||
class QueuePagingSource(
|
class QueuePagingSource(
|
||||||
private val repository: QueueRepository,
|
private val repository: QueueRepository,
|
||||||
|
private val initialPosition: Long,
|
||||||
) : PagingSource<Long, QueueWindowItem>() {
|
) : PagingSource<Long, QueueWindowItem>() {
|
||||||
override fun getRefreshKey(state: PagingState<Long, QueueWindowItem>): Long? =
|
override fun getRefreshKey(state: PagingState<Long, QueueWindowItem>): Long? =
|
||||||
state.anchorPosition?.let { (it / QUEUE_PAGE_SIZE).toLong() }
|
state.anchorPosition?.let { (it / QUEUE_PAGE_SIZE).toLong() }
|
||||||
|
|
||||||
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, QueueWindowItem> {
|
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, QueueWindowItem> {
|
||||||
val snapshot = repository.snapshot()
|
val snapshot = repository.snapshot()
|
||||||
val page = params.key ?: 0L
|
val page = params.key ?: (initialPosition.coerceAtLeast(0) / QUEUE_PAGE_SIZE)
|
||||||
val offset = page * QUEUE_PAGE_SIZE
|
val offset = page * QUEUE_PAGE_SIZE
|
||||||
val previousKey = if (page > 0) page - 1 else null
|
val previousKey = if (page > 0) page - 1 else null
|
||||||
if (offset >= snapshot.size) return LoadResult.Page(emptyList(), previousKey, null)
|
if (offset >= snapshot.size) return LoadResult.Page(emptyList(), previousKey, null)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.subtracks.data.repo
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
|
import androidx.room3.deferredTransaction
|
||||||
import androidx.room3.immediateTransaction
|
import androidx.room3.immediateTransaction
|
||||||
|
import androidx.room3.useReaderConnection
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
@@ -61,7 +63,12 @@ class QueueRepository(
|
|||||||
dao.setCursor(PlaybackCursor(queuePosition = 0))
|
dao.setCursor(PlaybackCursor(queuePosition = 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun snapshot(): QueueSnapshot = QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) })
|
suspend fun snapshot(): QueueSnapshot =
|
||||||
|
db.useReaderConnection { transactor ->
|
||||||
|
transactor.deferredTransaction {
|
||||||
|
QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun itemAt(
|
suspend fun itemAt(
|
||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
@@ -104,21 +111,22 @@ class QueueRepository(
|
|||||||
suspend fun removeAt(
|
suspend fun removeAt(
|
||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
position: Long,
|
position: Long,
|
||||||
) = write(removeEntry(snapshot.entries, position))
|
) = write(removeEntry(snapshot.entries, position).map { it.entry })
|
||||||
|
|
||||||
suspend fun move(
|
suspend fun move(
|
||||||
from: Long,
|
from: Long,
|
||||||
to: Long,
|
to: Long,
|
||||||
) {
|
): Boolean {
|
||||||
val before = snapshot()
|
val before = snapshot()
|
||||||
val located = before.locate(from) ?: return
|
val located = before.locate(from) ?: return false
|
||||||
val song = itemAt(before, from) ?: return
|
val song = itemAt(before, from) ?: return false
|
||||||
write(removeEntry(before.entries, from))
|
val removed = removeEntry(before.entries, from)
|
||||||
val after = snapshot()
|
val moved = songEntry(located.first.sourceId, song.song.id)
|
||||||
write(insertEntry(after.entries, to.coerceIn(0, after.size), songEntry(located.first.sourceId, song.song.id)))
|
write(insertEntry(removed, to.coerceIn(0, removed.sumOf { it.length }), moved))
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pagingSource(): PagingSource<Long, QueueWindowItem> = QueuePagingSource(this)
|
fun pagingSource(initialPosition: Long): PagingSource<Long, QueueWindowItem> = QueuePagingSource(this, initialPosition)
|
||||||
|
|
||||||
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
|
suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0
|
||||||
|
|
||||||
@@ -165,19 +173,23 @@ class QueueRepository(
|
|||||||
private fun removeEntry(
|
private fun removeEntry(
|
||||||
entries: List<ResolvedQueueEntry>,
|
entries: List<ResolvedQueueEntry>,
|
||||||
index: Long,
|
index: Long,
|
||||||
): List<QueueEntry> {
|
): List<ResolvedQueueEntry> {
|
||||||
val result = mutableListOf<QueueEntry>()
|
val result = mutableListOf<ResolvedQueueEntry>()
|
||||||
var start = 0L
|
var start = 0L
|
||||||
for (resolved in entries) {
|
for (resolved in entries) {
|
||||||
val end = start + resolved.length - 1
|
val end = start + resolved.length - 1
|
||||||
if (index in start..end) {
|
if (index in start..end) {
|
||||||
val offset = resolved.entry.offset + (index - start)
|
val offset = resolved.entry.offset + (index - start)
|
||||||
val first = resolved.entry.offset
|
val first = resolved.entry.offset
|
||||||
val last = resolved.entry.offset + resolved.length - 1
|
val last = first + resolved.length - 1
|
||||||
if (offset > first) result += resolved.entry.range(first, offset - 1)
|
if (offset > first) {
|
||||||
if (offset < last) result += resolved.entry.range(offset + 1, last)
|
result += ResolvedQueueEntry(resolved.entry.range(first, offset - 1), offset - first)
|
||||||
|
}
|
||||||
|
if (offset < last) {
|
||||||
|
result += ResolvedQueueEntry(resolved.entry.range(offset + 1, last), last - offset)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
result += resolved.entry
|
result += resolved
|
||||||
}
|
}
|
||||||
start += resolved.length
|
start += resolved.length
|
||||||
}
|
}
|
||||||
@@ -202,12 +214,11 @@ class QueueRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
!placed && index in start..end -> {
|
!placed && index in start..end -> {
|
||||||
val offset = resolved.entry.offset + (index - start)
|
val cut = index - start
|
||||||
val first = resolved.entry.offset
|
val offset = resolved.entry.offset
|
||||||
val last = resolved.entry.offset + resolved.length - 1
|
if (cut > 0) result += resolved.entry.range(offset, offset + cut - 1)
|
||||||
if (offset > first) result += resolved.entry.range(first, offset - 1)
|
|
||||||
result += newEntry
|
result += newEntry
|
||||||
if (offset <= last) result += resolved.entry.range(offset, last)
|
if (cut < resolved.length) result += resolved.entry.range(offset + cut, offset + resolved.length - 1)
|
||||||
placed = true
|
placed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ data class PlaybackState(
|
|||||||
val hasPrevious: Boolean = false,
|
val hasPrevious: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class QueueUndo(
|
||||||
|
val entries: List<QueueEntry>,
|
||||||
|
val cursor: Long,
|
||||||
|
)
|
||||||
|
|
||||||
class PlaybackController(
|
class PlaybackController(
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
@@ -72,6 +77,7 @@ class PlaybackController(
|
|||||||
private var windowStart = 0L
|
private var windowStart = 0L
|
||||||
private var windowEnd = -1L
|
private var windowEnd = -1L
|
||||||
private var updating = false
|
private var updating = false
|
||||||
|
private var lastEdit: QueueUndo? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -137,6 +143,7 @@ class PlaybackController(
|
|||||||
player.ensurePrepared()
|
player.ensurePrepared()
|
||||||
player.seekToIndex((target - windowStart).toInt())
|
player.seekToIndex((target - windowStart).toInt())
|
||||||
player.play()
|
player.play()
|
||||||
|
shiftWindowLocked(target)
|
||||||
refresh(target)
|
refresh(target)
|
||||||
} else {
|
} else {
|
||||||
loadWindow(target, autoplay = true)
|
loadWindow(target, autoplay = true)
|
||||||
@@ -150,24 +157,42 @@ class PlaybackController(
|
|||||||
val snapshot = snapshot ?: return@withLock
|
val snapshot = snapshot ?: return@withLock
|
||||||
if (position !in 0 until snapshot.size) return@withLock
|
if (position !in 0 until snapshot.size) return@withLock
|
||||||
val player = player ?: return@withLock
|
val player = player ?: return@withLock
|
||||||
|
val undo = QueueUndo(snapshot.entries.map { it.entry }, queueRepository.cursor())
|
||||||
if (snapshot.size == 1L) {
|
if (snapshot.size == 1L) {
|
||||||
queueRepository.removeAt(snapshot, position)
|
queueRepository.removeAt(snapshot, position)
|
||||||
stopLocked()
|
stopLocked()
|
||||||
|
lastEdit = undo
|
||||||
return@withLock
|
return@withLock
|
||||||
}
|
}
|
||||||
val current = currentPosition() ?: return@withLock
|
val current = currentPosition() ?: return@withLock
|
||||||
val target = (if (position < current) current - 1 else current).coerceIn(0, snapshot.size - 2)
|
val target = (if (position < current) current - 1 else current).coerceIn(0, snapshot.size - 2)
|
||||||
|
val playing = player.playWhenReady
|
||||||
queueRepository.removeAt(snapshot, position)
|
queueRepository.removeAt(snapshot, position)
|
||||||
this.snapshot = queueRepository.snapshot()
|
this.snapshot = queueRepository.snapshot()
|
||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
if (position < windowStart) {
|
lastEdit = undo
|
||||||
|
when {
|
||||||
|
position == current -> {
|
||||||
|
loadWindow(target, autoplay = playing, startPositionMs = 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
position < windowStart -> {
|
||||||
windowStart--
|
windowStart--
|
||||||
windowEnd--
|
windowEnd--
|
||||||
} else if (position <= windowEnd) {
|
shiftWindowLocked(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
position <= windowEnd -> {
|
||||||
updating = true
|
updating = true
|
||||||
player.removeAt((position - windowStart).toInt())
|
player.removeAt((position - windowStart).toInt())
|
||||||
updating = false
|
updating = false
|
||||||
windowEnd--
|
windowEnd--
|
||||||
|
shiftWindowLocked(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
shiftWindowLocked(target)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
queueRepository.setCursor(target)
|
queueRepository.setCursor(target)
|
||||||
refresh(target)
|
refresh(target)
|
||||||
@@ -182,10 +207,12 @@ class PlaybackController(
|
|||||||
if (from !in 0 until snapshot.size || to !in 0 until snapshot.size) return@withLock
|
if (from !in 0 until snapshot.size || to !in 0 until snapshot.size) return@withLock
|
||||||
val player = player ?: return@withLock
|
val player = player ?: return@withLock
|
||||||
val current = currentPosition() ?: return@withLock
|
val current = currentPosition() ?: return@withLock
|
||||||
val target = movedCursor(current, from, to)
|
val undo = QueueUndo(snapshot.entries.map { it.entry }, queueRepository.cursor())
|
||||||
queueRepository.move(from, to)
|
if (!queueRepository.move(from, to)) return@withLock
|
||||||
this.snapshot = queueRepository.snapshot()
|
this.snapshot = queueRepository.snapshot()
|
||||||
|
val target = movedCursor(current, from, to)
|
||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
|
lastEdit = undo
|
||||||
if (from in windowStart..windowEnd && to in windowStart..windowEnd) {
|
if (from in windowStart..windowEnd && to in windowStart..windowEnd) {
|
||||||
updating = true
|
updating = true
|
||||||
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
|
player.move((from - windowStart).toInt(), (to - windowStart).toInt())
|
||||||
@@ -193,6 +220,30 @@ class PlaybackController(
|
|||||||
} else {
|
} else {
|
||||||
loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs)
|
loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs)
|
||||||
}
|
}
|
||||||
|
shiftWindowLocked(target)
|
||||||
|
queueRepository.setCursor(target)
|
||||||
|
refresh(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun undo() =
|
||||||
|
startLock.withLock {
|
||||||
|
val undo = lastEdit ?: return@withLock
|
||||||
|
val player = player ?: return@withLock
|
||||||
|
val playing = player.playWhenReady
|
||||||
|
val playingId = player.currentItem?.id
|
||||||
|
val positionMs = player.currentPositionMs
|
||||||
|
queueRepository.replace(undo.entries)
|
||||||
|
val restored = queueRepository.snapshot()
|
||||||
|
this.snapshot = restored
|
||||||
|
lastEdit = null
|
||||||
|
if (restored.size == 0L) {
|
||||||
|
stopLocked()
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
|
val target = undo.cursor.coerceIn(0, restored.size - 1)
|
||||||
|
windowJob?.cancel()
|
||||||
|
val same = queueRepository.itemAt(restored, target)?.song?.id == playingId
|
||||||
|
loadWindow(target, autoplay = playing, startPositionMs = if (same) positionMs else 0)
|
||||||
queueRepository.setCursor(target)
|
queueRepository.setCursor(target)
|
||||||
refresh(target)
|
refresh(target)
|
||||||
}
|
}
|
||||||
@@ -242,6 +293,7 @@ class PlaybackController(
|
|||||||
windowEnd = -1
|
windowEnd = -1
|
||||||
lastDurationMs = 0
|
lastDurationMs = 0
|
||||||
lastError = null
|
lastError = null
|
||||||
|
lastEdit = null
|
||||||
queueRepository.replace(entries)
|
queueRepository.replace(entries)
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
this.snapshot = snapshot
|
this.snapshot = snapshot
|
||||||
@@ -290,6 +342,7 @@ class PlaybackController(
|
|||||||
queueSourceId = null
|
queueSourceId = null
|
||||||
windowStart = 0
|
windowStart = 0
|
||||||
windowEnd = -1
|
windowEnd = -1
|
||||||
|
lastEdit = null
|
||||||
player?.run {
|
player?.run {
|
||||||
stop()
|
stop()
|
||||||
clear()
|
clear()
|
||||||
@@ -359,12 +412,13 @@ class PlaybackController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun shiftWindow(center: Long) =
|
private suspend fun shiftWindow(center: Long) = startLock.withLock { shiftWindowLocked(center) }
|
||||||
startLock.withLock {
|
|
||||||
val player = player ?: return@withLock
|
private suspend fun shiftWindowLocked(center: Long) {
|
||||||
|
val player = player ?: return
|
||||||
val snapshot = queueRepository.snapshot()
|
val snapshot = queueRepository.snapshot()
|
||||||
this.snapshot = snapshot
|
this.snapshot = snapshot
|
||||||
if (snapshot.size == 0L) return@withLock
|
if (snapshot.size == 0L) return
|
||||||
val target = center.coerceIn(0, snapshot.size - 1)
|
val target = center.coerceIn(0, snapshot.size - 1)
|
||||||
val desiredStart = (target - QUEUE_WINDOW_RADIUS).coerceAtLeast(0)
|
val desiredStart = (target - QUEUE_WINDOW_RADIUS).coerceAtLeast(0)
|
||||||
val desiredEnd = (target + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1)
|
val desiredEnd = (target + QUEUE_WINDOW_RADIUS).coerceAtMost(snapshot.size - 1)
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -26,6 +30,7 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -41,7 +46,6 @@ import androidx.paging.LoadState
|
|||||||
import androidx.paging.Pager
|
import androidx.paging.Pager
|
||||||
import androidx.paging.PagingConfig
|
import androidx.paging.PagingConfig
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.cachedIn
|
|
||||||
import androidx.paging.compose.LazyPagingItems
|
import androidx.paging.compose.LazyPagingItems
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
import androidx.paging.compose.collectAsLazyPagingItems
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
@@ -54,14 +58,12 @@ import com.subtracks.playback.PlaybackController
|
|||||||
import com.subtracks.ui.components.EmptyState
|
import com.subtracks.ui.components.EmptyState
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.library.SongRow
|
import com.subtracks.ui.library.SongRow
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class QueueViewModel(
|
class QueueViewModel(
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val playbackController: PlaybackController,
|
private val playbackController: PlaybackController,
|
||||||
@@ -70,8 +72,8 @@ class QueueViewModel(
|
|||||||
|
|
||||||
val items: Flow<PagingData<QueueWindowItem>> =
|
val items: Flow<PagingData<QueueWindowItem>> =
|
||||||
Pager(PagingConfig(pageSize = QUEUE_PAGE_SIZE, enablePlaceholders = false)) {
|
Pager(PagingConfig(pageSize = QUEUE_PAGE_SIZE, enablePlaceholders = false)) {
|
||||||
QueuePagingSource(queueRepository).also { source = it }
|
QueuePagingSource(queueRepository, playbackController.state.value.position ?: 0L).also { source = it }
|
||||||
}.flow.cachedIn(viewModelScope)
|
}.flow
|
||||||
|
|
||||||
fun play(position: Long) {
|
fun play(position: Long) {
|
||||||
viewModelScope.launch { playbackController.playAt(position) }
|
viewModelScope.launch { playbackController.playAt(position) }
|
||||||
@@ -94,6 +96,13 @@ class QueueViewModel(
|
|||||||
source?.invalidate()
|
source?.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun undo() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
playbackController.undo()
|
||||||
|
source?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -113,6 +122,7 @@ fun QueueRoute(
|
|||||||
onPlay = viewModel::play,
|
onPlay = viewModel::play,
|
||||||
onRemove = viewModel::remove,
|
onRemove = viewModel::remove,
|
||||||
onMove = viewModel::move,
|
onMove = viewModel::move,
|
||||||
|
onUndo = viewModel::undo,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -127,22 +137,36 @@ fun QueueScreen(
|
|||||||
onPlay: (Long) -> Unit,
|
onPlay: (Long) -> Unit,
|
||||||
onRemove: (Long) -> Unit,
|
onRemove: (Long) -> Unit,
|
||||||
onMove: (Long, Long) -> Unit,
|
onMove: (Long, Long) -> Unit,
|
||||||
|
onUndo: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
var rowHeight by remember { mutableFloatStateOf(0f) }
|
var rowHeight by remember { mutableFloatStateOf(0f) }
|
||||||
var dragging by remember { mutableStateOf<Long?>(null) }
|
var dragging by remember { mutableStateOf<Long?>(null) }
|
||||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
var centered by remember { mutableStateOf(false) }
|
var centered by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(currentPosition, items.itemCount) {
|
val firstPosition = if (items.itemCount > 0) items.peek(0)?.position else null
|
||||||
|
LaunchedEffect(currentPosition, items.itemCount, firstPosition) {
|
||||||
val position = currentPosition ?: return@LaunchedEffect
|
val position = currentPosition ?: return@LaunchedEffect
|
||||||
if (!centered && position.toInt() < items.itemCount) {
|
val first = firstPosition ?: return@LaunchedEffect
|
||||||
listState.scrollToItem(position.toInt())
|
val index = position - first
|
||||||
|
if (!centered && index >= 0 && index < items.itemCount) {
|
||||||
|
listState.scrollToItem(index.toInt())
|
||||||
centered = true
|
centered = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val showUndo: (String) -> Unit = { message ->
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.currentSnackbarData?.dismiss()
|
||||||
|
val result = snackbarHostState.showSnackbar(message, actionLabel = "Undo", duration = SnackbarDuration.Short)
|
||||||
|
if (result == SnackbarResult.ActionPerformed) onUndo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -155,6 +179,7 @@ fun QueueScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) { padding ->
|
) { padding ->
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -181,7 +206,10 @@ fun QueueScreen(
|
|||||||
dragging = dragging == item.position,
|
dragging = dragging == item.position,
|
||||||
dragOffset = if (dragging == item.position) dragOffset else 0f,
|
dragOffset = if (dragging == item.position) dragOffset else 0f,
|
||||||
onClick = { onPlay(item.position) },
|
onClick = { onPlay(item.position) },
|
||||||
onRemove = { onRemove(item.position) },
|
onRemove = {
|
||||||
|
onRemove(item.position)
|
||||||
|
showUndo("Removed from queue")
|
||||||
|
},
|
||||||
onSize = { rowHeight = it.toFloat() },
|
onSize = { rowHeight = it.toFloat() },
|
||||||
onDragStart = {
|
onDragStart = {
|
||||||
dragging = item.position
|
dragging = item.position
|
||||||
@@ -191,11 +219,16 @@ fun QueueScreen(
|
|||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
val from = dragging
|
val from = dragging
|
||||||
if (from != null && rowHeight > 0f) {
|
if (from != null && rowHeight > 0f) {
|
||||||
val to =
|
val to = (from + (dragOffset / rowHeight).roundToInt()).coerceAtLeast(0)
|
||||||
(from + (dragOffset / rowHeight).roundToInt())
|
if (to != from) {
|
||||||
.coerceIn(0L, (items.itemCount - 1).toLong())
|
onMove(from, to)
|
||||||
if (to != from) onMove(from, to)
|
showUndo("Queue reordered")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
dragging = null
|
||||||
|
dragOffset = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
dragging = null
|
dragging = null
|
||||||
dragOffset = 0f
|
dragOffset = 0f
|
||||||
},
|
},
|
||||||
@@ -221,6 +254,7 @@ private fun QueueRow(
|
|||||||
onDragStart: () -> Unit,
|
onDragStart: () -> Unit,
|
||||||
onDrag: (Float) -> Unit,
|
onDrag: (Float) -> Unit,
|
||||||
onDragEnd: () -> Unit,
|
onDragEnd: () -> Unit,
|
||||||
|
onDragCancel: () -> Unit,
|
||||||
) {
|
) {
|
||||||
SongRow(
|
SongRow(
|
||||||
song = item.item.song,
|
song = item.item.song,
|
||||||
@@ -248,7 +282,7 @@ private fun QueueRow(
|
|||||||
onDrag(amount.y)
|
onDrag(amount.y)
|
||||||
},
|
},
|
||||||
onDragEnd = onDragEnd,
|
onDragEnd = onDragEnd,
|
||||||
onDragCancel = onDragEnd,
|
onDragCancel = onDragCancel,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class QueuePagingSourceTest {
|
|||||||
runTest {
|
runTest {
|
||||||
seedSongs(130)
|
seedSongs(130)
|
||||||
repository.replace(listOf(repository.songsEntry(1)))
|
repository.replace(listOf(repository.songsEntry(1)))
|
||||||
val source = repository.pagingSource()
|
val source = repository.pagingSource(initialPosition = 0)
|
||||||
|
|
||||||
val first = load(source, key = null)
|
val first = load(source, key = null)
|
||||||
assertEquals((0L until 60L).toList(), first.data.map { it.position })
|
assertEquals((0L until 60L).toList(), first.data.map { it.position })
|
||||||
@@ -60,10 +60,24 @@ class QueuePagingSourceTest {
|
|||||||
assertEquals(1L, third.prevKey)
|
assertEquals(1L, third.prevKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theFirstPageStartsAtTheInitialPosition() =
|
||||||
|
runTest {
|
||||||
|
seedSongs(130)
|
||||||
|
repository.replace(listOf(repository.songsEntry(1)))
|
||||||
|
val source = repository.pagingSource(initialPosition = 100)
|
||||||
|
|
||||||
|
val first = load(source, key = null)
|
||||||
|
|
||||||
|
assertEquals((60L until 120L).toList(), first.data.map { it.position })
|
||||||
|
assertEquals(2L, first.nextKey)
|
||||||
|
assertEquals(0L, first.prevKey)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun anEmptyQueueLoadsNothing() =
|
fun anEmptyQueueLoadsNothing() =
|
||||||
runTest {
|
runTest {
|
||||||
val result = load(repository.pagingSource(), key = null)
|
val result = load(repository.pagingSource(0), key = null)
|
||||||
|
|
||||||
assertEquals(emptyList<QueueWindowItem>(), result.data)
|
assertEquals(emptyList<QueueWindowItem>(), result.data)
|
||||||
assertEquals(null, result.nextKey)
|
assertEquals(null, result.nextKey)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import com.subtracks.data.model.Source
|
|||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -299,6 +301,17 @@ class QueueRepositoryTest {
|
|||||||
assertEquals(listOf("s2", "s3", "s4", "s1"), resolveAll(repository.snapshot()))
|
assertEquals(listOf("s2", "s3", "s4", "s1"), resolveAll(repository.snapshot()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingFromOutsideTheQueueChangesNothing() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
repository.snapshotAfter(listOf(repository.albumEntry(1, "al1")))
|
||||||
|
|
||||||
|
assertFalse(repository.move(from = 5, to = 0))
|
||||||
|
assertTrue(repository.move(from = 0, to = 2))
|
||||||
|
assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot()))
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
|
private suspend fun QueueRepository.snapshotAfter(entries: List<QueueEntry>): QueueSnapshot {
|
||||||
replace(entries)
|
replace(entries)
|
||||||
return snapshot()
|
return snapshot()
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ class PlaybackControllerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assertEquals(0L, controller.state.value.position)
|
assertEquals(0L, controller.state.value.position)
|
||||||
assertTrue(handle.operations.contains("removeAt(0)"))
|
assertTrue(handle.operations.any { it.startsWith("setWindow") })
|
||||||
assertFalse(controller.state.value.hasPrevious)
|
assertFalse(controller.state.value.hasPrevious)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +437,79 @@ class PlaybackControllerTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun undoRestoresARemovedTrack() {
|
||||||
|
seedAlbum(5, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 2)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s3"
|
||||||
|
}
|
||||||
|
|
||||||
|
runBlocking { controller.removeAt(3) }
|
||||||
|
await { handle.itemCount == 4 }
|
||||||
|
|
||||||
|
runBlocking { controller.undo() }
|
||||||
|
await { handle.itemCount == 5 }
|
||||||
|
|
||||||
|
assertEquals(listOf("s1", "s2", "s3", "s4", "s5"), handle.items.map { it.id })
|
||||||
|
assertEquals(
|
||||||
|
"s3",
|
||||||
|
controller.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(2L, controller.state.value.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun undoRestoresAReorderedQueue() {
|
||||||
|
seedAlbum(4, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
|
||||||
|
runBlocking { controller.move(0, 2) }
|
||||||
|
await { handle.items.map { it.id } == listOf("s2", "s3", "s1", "s4") }
|
||||||
|
|
||||||
|
runBlocking { controller.undo() }
|
||||||
|
await { handle.items.map { it.id } == listOf("s1", "s2", "s3", "s4") }
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"s1",
|
||||||
|
controller.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(0L, controller.state.value.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startingANewQueueClearsTheUndo() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
|
||||||
|
runBlocking { controller.removeAt(1) }
|
||||||
|
await { handle.itemCount == 2 }
|
||||||
|
|
||||||
|
controller.playAlbum(1, "al1", 2)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s3"
|
||||||
|
}
|
||||||
|
|
||||||
|
runBlocking { controller.undo() }
|
||||||
|
|
||||||
|
assertEquals(listOf("s1", "s2", "s3"), handle.items.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
private fun seedAlbum(
|
private fun seedAlbum(
|
||||||
count: Int,
|
count: Int,
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ class AppScreenshotTest {
|
|||||||
onPlay = {},
|
onPlay = {},
|
||||||
onRemove = {},
|
onRemove = {},
|
||||||
onMove = { _, _ -> },
|
onMove = { _, _ -> },
|
||||||
|
onUndo = {},
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Because the resolver reports *which entry* a position falls in, `PlaybackState`
|
|||||||
|
|
||||||
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL.
|
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL.
|
||||||
|
|
||||||
The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it pages with queries bounded to one page per entry (never the whole queue) and rewrites `queue_entries` to edit. Removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; a move that crosses the window boundary reloads the window around the cursor at the current position. Timed lyrics/gapless format preferences, scrobbling, downloads and adding to the queue from the library are not wired yet.
|
The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it pages with queries bounded to one page per entry (never the whole queue) and rewrites `queue_entries` to edit. Removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. Timed lyrics/gapless format preferences, scrobbling, downloads and adding to the queue from the library are not wired yet.
|
||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Not done yet in this slice (deliberately): artist detail, cover-art tonal colour
|
|||||||
- (done) A SQL-backed queue of references (whole playlist / album / song, each with an optional ordinal range) resolved lazily, with only a bounded window in memory and in the player.
|
- (done) A SQL-backed queue of references (whole playlist / album / song, each with an optional ordinal range) resolved lazily, with only a bounded window in memory and in the player.
|
||||||
- (done) Streaming bitrate and preferred format from settings (`maxBitRate`/`format`, so the server transcodes as configured).
|
- (done) Streaming bitrate and preferred format from settings (`maxBitRate`/`format`, so the server transcodes as configured).
|
||||||
- (done) Playing from the Songs tab: the tapped song queues the whole songs list at that position.
|
- (done) Playing from the Songs tab: the tapped song queues the whole songs list at that position.
|
||||||
- (done) A queue view that lists the resolved queue, jumps to a track on tap, removes tracks and reorders them by dragging.
|
- (done) A queue view that lists the resolved queue, jumps to a track on tap, removes tracks and reorders them by dragging, with a one-step undo.
|
||||||
- Adding to the queue from the library (play next / add to queue), and gapless format preferences.
|
- Adding to the queue from the library (play next / add to queue), and gapless format preferences.
|
||||||
- Scrobbling and "now playing" back to the server.
|
- Scrobbling and "now playing" back to the server.
|
||||||
- Optional download/caching.
|
- Optional download/caching.
|
||||||
|
|||||||
Reference in New Issue
Block a user