diff --git a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt index 4cd51c4c..7510b285 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -14,6 +14,11 @@ private const val ALBUM_SONGS_SQL = "WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId " + "ORDER BY songs.disc, songs.track" +private const val SONG_SQL = + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + + "WHERE songs.sourceId = :sourceId AND songs.id = :songId" + @Dao interface QueueDao { @Query("DELETE FROM queue_entries") @@ -56,33 +61,34 @@ interface QueueDao { songId: String, ): Long - @Query("$PLAYLIST_SONGS_SQL LIMIT 1 OFFSET :offset") - suspend fun playlistSongAt( + @Query("$PLAYLIST_SONGS_SQL LIMIT :limit OFFSET :offset") + suspend fun playlistSongs( sourceId: Long, playlistId: String, offset: Long, - ): SongListItem? + limit: Int, + ): List - @Query("$ALBUM_SONGS_SQL LIMIT 1 OFFSET :offset") - suspend fun albumSongAt( + @Query("$ALBUM_SONGS_SQL LIMIT :limit OFFSET :offset") + suspend fun albumSongs( sourceId: Long, albumId: String, offset: Long, - ): SongListItem? + limit: Int, + ): List - @Query("$SONGS_SQL LIMIT 1 OFFSET :offset") - suspend fun songAt( + @Query("$SONGS_SQL LIMIT :limit OFFSET :offset") + suspend fun songs( sourceId: Long, offset: Long, - ): SongListItem? + limit: Int, + ): List - @Query( - "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + - "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + - "WHERE songs.sourceId = :sourceId AND songs.id = :songId LIMIT 1", - ) + @Query("$SONG_SQL LIMIT :limit OFFSET :offset") suspend fun song( sourceId: Long, songId: String, - ): SongListItem? + offset: Long, + limit: Int, + ): List } diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueuePagingSource.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueuePagingSource.kt new file mode 100644 index 00000000..bb07f29d --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueuePagingSource.kt @@ -0,0 +1,27 @@ +package com.subtracks.data.repo + +import androidx.paging.PagingSource +import androidx.paging.PagingState + +const val QUEUE_PAGE_SIZE = 60 + +class QueuePagingSource( + private val repository: QueueRepository, +) : PagingSource() { + override fun getRefreshKey(state: PagingState): Long? = + state.anchorPosition?.let { (it / QUEUE_PAGE_SIZE).toLong() } + + override suspend fun load(params: LoadParams): LoadResult { + val snapshot = repository.snapshot() + val page = params.key ?: 0L + val offset = page * QUEUE_PAGE_SIZE + val previousKey = if (page > 0) page - 1 else null + if (offset >= snapshot.size) return LoadResult.Page(emptyList(), previousKey, null) + val end = (offset + params.loadSize).coerceAtMost(snapshot.size) + return LoadResult.Page( + data = repository.range(snapshot, offset, end - 1), + prevKey = previousKey, + nextKey = if (end < snapshot.size) end / QUEUE_PAGE_SIZE else null, + ) + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt index faca4913..ee3ec20a 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -1,5 +1,6 @@ package com.subtracks.data.repo +import androidx.paging.PagingSource import androidx.room3.immediateTransaction import androidx.room3.useWriterConnection import com.subtracks.data.db.SubtracksDatabase @@ -56,13 +57,8 @@ class QueueRepository( fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "") suspend fun replace(entries: List) { - db.useWriterConnection { transactor -> - transactor.immediateTransaction { - dao.clear() - dao.insert(entries) - dao.setCursor(PlaybackCursor(queuePosition = 0)) - } - } + write(entries) + dao.setCursor(PlaybackCursor(queuePosition = 0)) } suspend fun snapshot(): QueueSnapshot = QueueSnapshot(dao.entries().map { ResolvedQueueEntry(it, it.resolvedLength()) }) @@ -72,7 +68,28 @@ class QueueRepository( position: Long, ): SongListItem? { val (entry, offset) = snapshot.locate(position) ?: return null - return itemWithin(entry, offset) + return rows(entry, entry.offset + offset, 1).firstOrNull() + } + + suspend fun range( + snapshot: QueueSnapshot, + first: Long, + last: Long, + ): List { + val items = mutableListOf() + var start = 0L + for (resolved in snapshot.entries) { + val end = start + resolved.length - 1 + val entryStart = start + start += resolved.length + if (resolved.length == 0L || end < first || entryStart > last) continue + val from = maxOf(first, entryStart) + val to = minOf(last, end) + val offset = resolved.entry.offset + (from - entryStart) + rows(resolved.entry, offset, (to - from + 1).toInt()) + .forEachIndexed { index, item -> items += QueueWindowItem(from + index, item) } + } + return items } suspend fun window( @@ -81,30 +98,53 @@ class QueueRepository( radius: Long, ): List { if (snapshot.size == 0L) return emptyList() - val first = (center - radius).coerceAtLeast(0) - val last = (center + radius).coerceAtMost(snapshot.size - 1) - return (first..last).mapNotNull { position -> - itemAt(snapshot, position)?.let { QueueWindowItem(position, it) } - } + return range(snapshot, (center - radius).coerceAtLeast(0), (center + radius).coerceAtMost(snapshot.size - 1)) } + suspend fun removeAt( + snapshot: QueueSnapshot, + position: Long, + ) = write(removeEntry(snapshot.entries, position)) + + suspend fun move( + from: Long, + to: Long, + ) { + val before = snapshot() + val located = before.locate(from) ?: return + val song = itemAt(before, from) ?: return + write(removeEntry(before.entries, from)) + val after = snapshot() + write(insertEntry(after.entries, to.coerceIn(0, after.size), songEntry(located.first.sourceId, song.song.id))) + } + + fun pagingSource(): PagingSource = QueuePagingSource(this) + suspend fun cursor(): Long = dao.cursor()?.queuePosition ?: 0 suspend fun setCursor(position: Long) = dao.setCursor(PlaybackCursor(queuePosition = position)) - private suspend fun itemWithin( - entry: QueueEntry, - offset: Long, - ): SongListItem? { - val index = entry.offset + offset - return when (entry.kind) { - QueueKind.Playlist -> dao.playlistSongAt(entry.sourceId, entry.refId, index) - QueueKind.Album -> dao.albumSongAt(entry.sourceId, entry.refId, index) - QueueKind.Song -> if (offset == 0L) dao.song(entry.sourceId, entry.refId) else null - QueueKind.Songs -> dao.songAt(entry.sourceId, index) + private suspend fun write(entries: List) { + db.useWriterConnection { transactor -> + transactor.immediateTransaction { + dao.clear() + dao.insert(entries.mapIndexed { index, entry -> entry.copy(id = 0, position = index.toLong()) }) + } } } + private suspend fun rows( + entry: QueueEntry, + offset: Long, + limit: Int, + ): List = + when (entry.kind) { + QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit) + QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit) + QueueKind.Songs -> dao.songs(entry.sourceId, offset, limit) + QueueKind.Song -> dao.song(entry.sourceId, entry.refId, offset, limit) + } + private suspend fun QueueEntry.resolvedLength(): Long { val total = when (kind) { @@ -116,4 +156,68 @@ class QueueRepository( val available = (total - offset).coerceAtLeast(0) return count?.coerceAtMost(available) ?: available } + + private fun QueueEntry.range( + start: Long, + end: Long, + ) = copy(id = 0, position = 0, rangeStart = start, rangeEnd = end) + + private fun removeEntry( + entries: List, + index: Long, + ): List { + val result = mutableListOf() + var start = 0L + for (resolved in entries) { + val end = start + resolved.length - 1 + if (index in start..end) { + val offset = resolved.entry.offset + (index - start) + val first = resolved.entry.offset + val last = resolved.entry.offset + resolved.length - 1 + if (offset > first) result += resolved.entry.range(first, offset - 1) + if (offset < last) result += resolved.entry.range(offset + 1, last) + } else { + result += resolved.entry + } + start += resolved.length + } + return result + } + + private fun insertEntry( + entries: List, + index: Long, + newEntry: QueueEntry, + ): List { + val result = mutableListOf() + var start = 0L + var placed = false + for (resolved in entries) { + val end = start + resolved.length - 1 + when { + !placed && index <= start -> { + result += newEntry + result += resolved.entry + placed = true + } + + !placed && index in start..end -> { + val offset = resolved.entry.offset + (index - start) + val first = resolved.entry.offset + val last = resolved.entry.offset + resolved.length - 1 + if (offset > first) result += resolved.entry.range(first, offset - 1) + result += newEntry + if (offset <= last) result += resolved.entry.range(offset, last) + placed = true + } + + else -> { + result += resolved.entry + } + } + start += resolved.length + } + if (!placed) result += newEntry + return result + } } diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index b1f44877..04f83ad8 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -14,6 +14,7 @@ import com.subtracks.ui.RootViewModel import com.subtracks.ui.library.AlbumDetailViewModel import com.subtracks.ui.library.LibraryViewModel import com.subtracks.ui.library.PlaylistDetailViewModel +import com.subtracks.ui.playback.QueueViewModel import com.subtracks.ui.settings.AddSourceViewModel import com.subtracks.ui.settings.SettingsViewModel import okhttp3.OkHttpClient @@ -39,4 +40,5 @@ fun appModule( viewModel { AddSourceViewModel(get(), get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) } viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) } + viewModel { QueueViewModel(get(), get()) } } diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 115a9010..4ac7e591 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -39,7 +39,8 @@ class Media3PlayerHandle( override fun setWindow( items: List, startIndex: Int, - ) = controller.setMediaItems(items.map(::toMediaItem), startIndex, 0) + startPositionMs: Long, + ) = controller.setMediaItems(items.map(::toMediaItem), startIndex, startPositionMs) override fun addFirst(item: QueueItem) = controller.addMediaItems(0, listOf(toMediaItem(item))) @@ -49,6 +50,13 @@ class Media3PlayerHandle( override fun removeLast() = controller.removeMediaItem(controller.mediaItemCount - 1) + override fun removeAt(index: Int) = controller.removeMediaItem(index) + + override fun move( + from: Int, + to: Int, + ) = controller.moveMediaItem(from, to) + override fun prepare() = controller.prepare() override fun play() = controller.play() diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index beea1275..edd2e90a 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -36,6 +36,7 @@ data class QueueContext( data class PlaybackState( val item: QueueItem? = null, val context: QueueContext? = null, + val position: Long? = null, val error: String? = null, val isBuffering: Boolean = false, val isPlaying: Boolean = false, @@ -123,6 +124,79 @@ class PlaybackController( startPosition: Long, ) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition) + fun playAt(position: Long) { + scope.launch { + startLock.withLock { + val snapshot = snapshot ?: return@withLock + if (snapshot.size == 0L) return@withLock + val player = player ?: return@withLock + val target = position.coerceIn(0, snapshot.size - 1) + windowJob?.cancel() + queueRepository.setCursor(target) + if (target in windowStart..windowEnd) { + player.ensurePrepared() + player.seekToIndex((target - windowStart).toInt()) + player.play() + refresh(target) + } else { + loadWindow(target, autoplay = true) + } + } + } + } + + suspend fun removeAt(position: Long) = + startLock.withLock { + val snapshot = snapshot ?: return@withLock + if (position !in 0 until snapshot.size) return@withLock + val player = player ?: return@withLock + if (snapshot.size == 1L) { + queueRepository.removeAt(snapshot, position) + stopLocked() + return@withLock + } + val current = currentPosition() ?: return@withLock + val target = (if (position < current) current - 1 else current).coerceIn(0, snapshot.size - 2) + queueRepository.removeAt(snapshot, position) + this.snapshot = queueRepository.snapshot() + windowJob?.cancel() + if (position < windowStart) { + windowStart-- + windowEnd-- + } else if (position <= windowEnd) { + updating = true + player.removeAt((position - windowStart).toInt()) + updating = false + windowEnd-- + } + queueRepository.setCursor(target) + refresh(target) + } + + suspend fun move( + from: Long, + to: Long, + ) = startLock.withLock { + if (from == to) return@withLock + val snapshot = snapshot ?: return@withLock + if (from !in 0 until snapshot.size || to !in 0 until snapshot.size) return@withLock + val player = player ?: return@withLock + val current = currentPosition() ?: return@withLock + val target = movedCursor(current, from, to) + queueRepository.move(from, to) + this.snapshot = queueRepository.snapshot() + windowJob?.cancel() + if (from in windowStart..windowEnd && to in windowStart..windowEnd) { + updating = true + player.move((from - windowStart).toInt(), (to - windowStart).toInt()) + updating = false + } else { + loadWindow(target, autoplay = player.playWhenReady, startPositionMs = player.currentPositionMs) + } + queueRepository.setCursor(target) + refresh(target) + } + fun togglePlayPause() { val player = player ?: return if (player.isEnded) { @@ -227,6 +301,7 @@ class PlaybackController( private suspend fun loadWindow( position: Long, autoplay: Boolean, + startPositionMs: Long = 0, ) { val player = player ?: return val snapshot = snapshot ?: return @@ -236,7 +311,7 @@ class PlaybackController( windowStart = window.first().position windowEnd = window.last().position updating = true - player.setWindow(window.map { it.item.toQueueItem() }, startIndex) + player.setWindow(window.map { it.item.toQueueItem() }, startIndex, startPositionMs) updating = false if (autoplay) { player.prepare() @@ -324,6 +399,18 @@ class PlaybackController( return windowStart + player.currentIndex } + private fun movedCursor( + current: Long, + from: Long, + to: Long, + ): Long = + when { + from == current -> to + from < current && to >= current -> current - 1 + from > current && to <= current -> current + 1 + else -> current + } + private val playerListener = object : PlayerHandle.Listener { override fun onTransition() { @@ -372,6 +459,7 @@ class PlaybackController( PlaybackState( item = player.currentItem, context = contextAt(position), + position = position, error = lastError, isBuffering = showBuffering, isPlaying = player.playWhenReady && !player.isIdle && !player.isEnded, diff --git a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt index 70a0fe82..8c2209ee 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlayerHandle.kt @@ -15,6 +15,7 @@ interface PlayerHandle { fun setWindow( items: List, startIndex: Int, + startPositionMs: Long = 0, ) fun addFirst(item: QueueItem) @@ -25,6 +26,13 @@ interface PlayerHandle { fun removeLast() + fun removeAt(index: Int) + + fun move( + from: Int, + to: Int, + ) + fun prepare() fun play() diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index 6a4d5c99..f150069b 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -41,6 +41,7 @@ import com.subtracks.ui.library.LibraryRoute import com.subtracks.ui.library.PlaylistDetailRoute import com.subtracks.ui.playback.MiniPlayer import com.subtracks.ui.playback.NowPlayingRoute +import com.subtracks.ui.playback.QueueRoute import com.subtracks.ui.settings.AddSourceRoute import com.subtracks.ui.settings.SettingsRoute import kotlinx.coroutines.flow.SharingStarted @@ -89,6 +90,7 @@ private fun MainNavigation() { val playbackController = koinInject() val playback by playbackController.state.collectAsStateWithLifecycle() var showingNowPlaying by rememberSaveable { mutableStateOf(false) } + var showingQueue by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { playbackController.connect() } val playerVisible = playback.item != null @@ -185,10 +187,23 @@ private fun MainNavigation() { ) { NowPlayingRoute( onBack = { showingNowPlaying = false }, + onQueue = { showingQueue = true }, + modifier = Modifier.fillMaxSize(), + ) + } + + AnimatedVisibility( + visible = showingQueue, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(tween(NOW_PLAYING_DURATION_MS)), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(tween(NOW_PLAYING_DURATION_MS)), + ) { + QueueRoute( + onBack = { showingQueue = false }, modifier = Modifier.fillMaxSize(), ) } } - BackHandler(enabled = showingNowPlaying) { showingNowPlaying = false } + BackHandler(enabled = showingQueue) { showingQueue = false } + BackHandler(enabled = showingNowPlaying && !showingQueue) { showingNowPlaying = false } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt index 92349aed..fcc27398 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -78,6 +78,7 @@ fun SongRow( coverArtId: String? = null, coverArt: ((String?, Boolean) -> CoverArtRef?)? = null, isPlaying: Boolean = false, + trailingContent: (@Composable () -> Unit)? = null, modifier: Modifier = Modifier, ) { ListItem( @@ -94,6 +95,7 @@ fun SongRow( } else { null }, + trailingContent = trailingContent, headlineContent = { Row( verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index 69926a70..3d6bc6c1 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.rounded.PauseCircle import androidx.compose.material.icons.rounded.PlayCircle import androidx.compose.material.icons.rounded.SkipNext @@ -49,6 +50,7 @@ import org.koin.compose.koinInject @Composable fun NowPlayingRoute( onBack: () -> Unit, + onQueue: () -> Unit, modifier: Modifier = Modifier, controller: PlaybackController = koinInject(), ) { @@ -57,6 +59,7 @@ fun NowPlayingRoute( state = state, coverArt = controller.coverArt(state.item), onBack = onBack, + onQueue = onQueue, onPlayPause = controller::togglePlayPause, onNext = controller::next, onPrevious = controller::previous, @@ -71,6 +74,7 @@ fun NowPlayingScreen( state: PlaybackState, coverArt: CoverArtRef?, onBack: () -> Unit, + onQueue: () -> Unit, onPlayPause: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, @@ -90,6 +94,11 @@ fun NowPlayingScreen( Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") } }, + actions = { + IconButton(onClick = onQueue) { + Icon(Icons.AutoMirrored.Rounded.QueueMusic, contentDescription = "Queue") + } + }, ) }, ) { padding -> diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt new file mode 100644 index 00000000..3ddb570c --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/playback/QueueScreen.kt @@ -0,0 +1,267 @@ +package com.subtracks.ui.playback + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.DragHandle +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.paging.LoadState +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.repo.QUEUE_PAGE_SIZE +import com.subtracks.data.repo.QueuePagingSource +import com.subtracks.data.repo.QueueRepository +import com.subtracks.data.repo.QueueWindowItem +import com.subtracks.data.repo.SourceRepository +import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.EmptyState +import com.subtracks.ui.components.LoadingState +import com.subtracks.ui.library.SongRow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import kotlin.math.roundToInt + +@OptIn(ExperimentalCoroutinesApi::class) +class QueueViewModel( + private val queueRepository: QueueRepository, + private val playbackController: PlaybackController, +) : ViewModel() { + private var source: QueuePagingSource? = null + + val items: Flow> = + Pager(PagingConfig(pageSize = QUEUE_PAGE_SIZE, enablePlaceholders = false)) { + QueuePagingSource(queueRepository).also { source = it } + }.flow.cachedIn(viewModelScope) + + fun play(position: Long) { + viewModelScope.launch { playbackController.playAt(position) } + } + + fun remove(position: Long) { + viewModelScope.launch { + playbackController.removeAt(position) + source?.invalidate() + } + } + + fun move( + from: Long, + to: Long, + ) { + if (from == to) return + viewModelScope.launch { + playbackController.move(from, to) + source?.invalidate() + } + } +} + +@Composable +fun QueueRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: QueueViewModel = koinViewModel(), + controller: PlaybackController = koinInject(), + sourceRepository: SourceRepository = koinInject(), +) { + val playback by controller.state.collectAsStateWithLifecycle() + QueueScreen( + items = viewModel.items.collectAsLazyPagingItems(), + currentPosition = playback.position, + coverArt = sourceRepository::coverArt, + onBack = onBack, + onPlay = viewModel::play, + onRemove = viewModel::remove, + onMove = viewModel::move, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QueueScreen( + items: LazyPagingItems, + currentPosition: Long?, + coverArt: (String?, Boolean) -> CoverArtRef?, + onBack: () -> Unit, + onPlay: (Long) -> Unit, + onRemove: (Long) -> Unit, + onMove: (Long, Long) -> Unit, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + var rowHeight by remember { mutableFloatStateOf(0f) } + var dragging by remember { mutableStateOf(null) } + var dragOffset by remember { mutableFloatStateOf(0f) } + var centered by remember { mutableStateOf(false) } + + LaunchedEffect(currentPosition, items.itemCount) { + val position = currentPosition ?: return@LaunchedEffect + if (!centered && position.toInt() < items.itemCount) { + listState.scrollToItem(position.toInt()) + centered = true + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text("Queue") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + when { + items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { + LoadingState(Modifier.padding(padding)) + } + + items.itemCount == 0 -> { + EmptyState("The queue is empty.", Modifier.padding(padding)) + } + + else -> { + LazyColumn( + state = listState, + modifier = Modifier.padding(padding).fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp), + ) { + items(count = items.itemCount) { index -> + val item = items[index] + if (item != null) { + QueueRow( + item = item, + isPlaying = item.position == currentPosition, + coverArt = coverArt, + dragging = dragging == item.position, + dragOffset = if (dragging == item.position) dragOffset else 0f, + onClick = { onPlay(item.position) }, + onRemove = { onRemove(item.position) }, + onSize = { rowHeight = it.toFloat() }, + onDragStart = { + dragging = item.position + dragOffset = 0f + }, + onDrag = { dragOffset += it }, + onDragEnd = { + val from = dragging + if (from != null && rowHeight > 0f) { + val to = + (from + (dragOffset / rowHeight).roundToInt()) + .coerceIn(0L, (items.itemCount - 1).toLong()) + if (to != from) onMove(from, to) + } + dragging = null + dragOffset = 0f + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun QueueRow( + item: QueueWindowItem, + isPlaying: Boolean, + coverArt: (String?, Boolean) -> CoverArtRef?, + dragging: Boolean, + dragOffset: Float, + onClick: () -> Unit, + onRemove: () -> Unit, + onSize: (Int) -> Unit, + onDragStart: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, +) { + SongRow( + song = item.item.song, + coverArtId = item.item.coverArt, + coverArt = coverArt, + isPlaying = isPlaying, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = "Remove from queue", + modifier = Modifier.size(20.dp), + ) + } + Box( + modifier = + Modifier + .size(40.dp) + .pointerInput(item.position) { + detectDragGestures( + onDragStart = { onDragStart() }, + onDrag = { change, amount -> + change.consume() + onDrag(amount.y) + }, + onDragEnd = onDragEnd, + onDragCancel = onDragEnd, + ) + }, + contentAlignment = Alignment.Center, + ) { + Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = "Reorder") + } + } + }, + modifier = + Modifier + .zIndex(if (dragging) 1f else 0f) + .graphicsLayer { translationY = dragOffset } + .onSizeChanged { size -> onSize(size.height) } + .clickable(onClick = onClick), + ) +} diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueuePagingSourceTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueuePagingSourceTest.kt new file mode 100644 index 00000000..a98b69e0 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueuePagingSourceTest.kt @@ -0,0 +1,104 @@ +package com.subtracks.data.repo + +import android.content.Context +import androidx.paging.PagingSource +import androidx.room3.Room +import androidx.sqlite.driver.bundled.BundledSQLiteDriver +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.db.SubtracksDatabase +import com.subtracks.data.model.Song +import com.subtracks.data.model.Source +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QueuePagingSourceTest { + private lateinit var db: SubtracksDatabase + private lateinit var repository: QueueRepository + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = + Room + .inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .build() + repository = QueueRepository(db) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun pagesAreContiguousAndDoNotOverlap() = + runTest { + seedSongs(130) + repository.replace(listOf(repository.songsEntry(1))) + val source = repository.pagingSource() + + val first = load(source, key = null) + assertEquals((0L until 60L).toList(), first.data.map { it.position }) + assertEquals(1L, first.nextKey) + assertEquals(null, first.prevKey) + + val second = load(source, key = 1L) + assertEquals((60L until 120L).toList(), second.data.map { it.position }) + assertEquals(2L, second.nextKey) + assertEquals(0L, second.prevKey) + + val third = load(source, key = 2L) + assertEquals((120L until 130L).toList(), third.data.map { it.position }) + assertEquals(null, third.nextKey) + assertEquals(1L, third.prevKey) + } + + @Test + fun anEmptyQueueLoadsNothing() = + runTest { + val result = load(repository.pagingSource(), key = null) + + assertEquals(emptyList(), result.data) + assertEquals(null, result.nextKey) + assertEquals(null, result.prevKey) + } + + private suspend fun load( + source: PagingSource, + key: Long?, + ): PagingSource.LoadResult.Page { + val params = PagingSource.LoadParams.Refresh(key, QUEUE_PAGE_SIZE, false) + return source.load(params) as PagingSource.LoadResult.Page + } + + private suspend fun seedSongs(count: Int) { + db.sourcesDao().upsertSource( + Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0), + ) + db.libraryDao().upsertSongs( + (1..count).map { track -> + Song( + sourceId = 1, + id = "s$track", + albumId = "al1", + artistId = "ar1", + title = "Song $track", + album = "Album", + artist = "Artist", + duration = 100, + track = track.toLong(), + disc = 1, + starred = null, + genre = null, + ) + }, + ) + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt index 132b3eb6..a85d02a2 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -228,6 +228,77 @@ class QueueRepositoryTest { assertEquals(listOf("s4"), resolveAll(snapshot)) } + @Test + fun rangeResolvesASliceSpanningEntries() = + runTest { + seedLibrary() + val entries = listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4")) + val snapshot = repository.snapshotAfter(entries) + + val range = repository.range(snapshot, first = 1, last = 3) + + assertEquals(listOf(1L, 2L, 3L), range.map { it.position }) + assertEquals(listOf("s2", "s3", "s4"), range.map { it.item.song.id }) + } + + @Test + fun removingInsideAnEntrySplitsItIntoTwoRanges() = + runTest { + seedLibrary() + val snapshot = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + + repository.removeAt(snapshot, 1) + + assertEquals(listOf("s1", "s3"), resolveAll(repository.snapshot())) + } + + @Test + fun removingTheFirstOrLastTrackKeepsTheRemainingRange() = + runTest { + seedLibrary() + val first = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.removeAt(first, 0) + assertEquals(listOf("s2", "s3"), resolveAll(repository.snapshot())) + + val last = repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + repository.removeAt(last, 2) + assertEquals(listOf("s1", "s2"), resolveAll(repository.snapshot())) + } + + @Test + fun removingASongEntryDropsIt() = + runTest { + seedLibrary() + val snapshot = + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4"))) + + repository.removeAt(snapshot, 3) + + assertEquals(listOf("s1", "s2", "s3"), resolveAll(repository.snapshot())) + } + + @Test + fun movingWithinAnEntryReordersTheQueue() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"))) + + repository.move(from = 0, to = 2) + + assertEquals(listOf("s2", "s3", "s1"), resolveAll(repository.snapshot())) + } + + @Test + fun movingAcrossEntriesKeepsTheRestInOrder() = + runTest { + seedLibrary() + repository.snapshotAfter(listOf(repository.albumEntry(1, "al1"), repository.songEntry(1, "s4"))) + + repository.move(from = 0, to = 3) + + assertEquals(listOf("s2", "s3", "s4", "s1"), resolveAll(repository.snapshot())) + } + private suspend fun QueueRepository.snapshotAfter(entries: List): QueueSnapshot { replace(entries) return snapshot() diff --git a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt index 4e40cd32..49d65c71 100644 --- a/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt +++ b/app/src/test/kotlin/com/subtracks/playback/FakePlayer.kt @@ -61,8 +61,9 @@ class FakePlayerHandle : PlayerHandle { override fun setWindow( items: List, startIndex: Int, + startPositionMs: Long, ) { - operations += "setWindow(size=${items.size}, start=$startIndex)" + operations += "setWindow(size=${items.size}, start=$startIndex, position=$startPositionMs)" this.items.clear() this.items += items index = startIndex @@ -96,6 +97,34 @@ class FakePlayerHandle : PlayerHandle { items.removeAt(items.size - 1) } + override fun removeAt(index: Int) { + operations += "removeAt($index)" + items.removeAt(index) + this.index = + when { + index < this.index -> this.index - 1 + index == this.index -> this.index.coerceAtMost(items.size - 1) + else -> this.index + } + notifyEvents() + } + + override fun move( + from: Int, + to: Int, + ) { + operations += "move($from, $to)" + items.add(to, items.removeAt(from)) + index = + when { + index == from -> to + from < index && to >= index -> index - 1 + from > index && to <= index -> index + 1 + else -> index + } + notifyEvents() + } + override fun prepare() { operations += "prepare" idle = false diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 5da5bd6f..c0b99cc0 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -311,6 +311,132 @@ class PlaybackControllerTest { assertFalse(controller.state.value.hasPrevious) } + @Test + fun jumpingToAVisibleItemSeeksWithinTheWindow() { + seedAlbum(60, sourceId = 1) + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + controller.playAt(10) + await { controller.state.value.position == 10L } + + assertTrue(handle.operations.contains("seekToIndex(10)")) + assertEquals( + "s11", + controller.state.value.item + ?.id, + ) + } + + @Test + fun jumpingOutsideTheWindowReloadsAroundTheTarget() { + seedAlbum(60, sourceId = 1) + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + controller.playAt(40) + await { + controller.state.value.item + ?.id == "s41" + } + + assertEquals(40L, controller.state.value.position) + assertTrue(handle.operations.any { it.startsWith("setWindow") }) + assertTrue(controller.state.value.hasPrevious) + } + + @Test + fun removingTheCurrentTrackPlaysTheNextOne() { + seedAlbum(5, sourceId = 1) + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + runBlocking { controller.removeAt(0) } + await { + controller.state.value.item + ?.id == "s2" + } + + assertEquals(0L, controller.state.value.position) + assertTrue(handle.operations.contains("removeAt(0)")) + assertFalse(controller.state.value.hasPrevious) + } + + @Test + fun removingALaterTrackKeepsTheCurrentOne() { + seedAlbum(5, sourceId = 1) + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + runBlocking { controller.removeAt(3) } + await { handle.itemCount == 4 } + + assertEquals( + "s1", + controller.state.value.item + ?.id, + ) + assertEquals(listOf("s1", "s2", "s3", "s5"), handle.items.map { it.id }) + } + + @Test + fun movingATrackReordersTheWindow() { + seedAlbum(4, sourceId = 1) + + controller.playAlbum(1, "al1", 0) + await { + controller.state.value.item + ?.id == "s1" + } + + runBlocking { controller.move(0, 2) } + await { controller.state.value.position == 2L } + + assertEquals(listOf("s2", "s3", "s1", "s4"), handle.items.map { it.id }) + assertEquals( + "s1", + controller.state.value.item + ?.id, + ) + } + + @Test + fun movingALaterTrackBeforeTheCurrentShiftsTheCursor() { + seedAlbum(4, sourceId = 1) + + controller.playAlbum(1, "al1", 2) + await { + controller.state.value.item + ?.id == "s3" + } + + runBlocking { controller.move(3, 0) } + await { controller.state.value.position == 3L } + + assertEquals(listOf("s4", "s1", "s2", "s3"), handle.items.map { it.id }) + assertEquals( + "s3", + controller.state.value.item + ?.id, + ) + } + private fun seedAlbum( count: Int, sourceId: Long, diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 7507cc48..8960303e 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -34,6 +34,7 @@ import com.subtracks.data.model.Playlist import com.subtracks.data.model.Song import com.subtracks.data.model.SongListItem import com.subtracks.data.model.Source +import com.subtracks.data.repo.QueueWindowItem import com.subtracks.playback.PlaybackState import com.subtracks.playback.QueueItem import com.subtracks.ui.library.ALBUM_COVER_TAG @@ -43,6 +44,7 @@ import com.subtracks.ui.library.LibraryTab import com.subtracks.ui.library.PlaylistDetailScreen import com.subtracks.ui.playback.MiniPlayer import com.subtracks.ui.playback.NowPlayingScreen +import com.subtracks.ui.playback.QueueScreen import com.subtracks.ui.settings.AddSourceScreen import com.subtracks.ui.settings.AddSourceState import com.subtracks.ui.settings.SettingsScreen @@ -203,6 +205,7 @@ class AppScreenshotTest { state = Fixtures.playbackState(), coverArt = CoverArtRef("art-al-kid-a", "test:art-al-kid-a"), onBack = {}, + onQueue = {}, onPlayPause = {}, onNext = {}, onPrevious = {}, @@ -215,6 +218,33 @@ class AppScreenshotTest { composeRule.onRoot().captureRoboImage("src/test/screenshots/now_playing.png") } + @Test + fun queue() { + composeRule.setContent { + SubtracksTheme { + QueueScreen( + items = + remember { + flowOf( + PagingData.from( + Fixtures.songItems.mapIndexed { index, item -> QueueWindowItem(index.toLong(), item) }, + ), + ) + }.collectAsLazyPagingItems(), + currentPosition = 1, + coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, + onBack = {}, + onPlay = {}, + onRemove = {}, + onMove = { _, _ -> }, + modifier = Modifier.fillMaxSize(), + ) + } + } + awaitText("Everything In Its Right Place") + composeRule.onRoot().captureRoboImage("src/test/screenshots/queue.png") + } + @Test fun miniPlayer() { composeRule.setContent { diff --git a/docs/architecture.md b/docs/architecture.md index a4871d1d..96a54961 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. -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. Timed lyrics/gapless format preferences, bitrate/format preferences, scrobbling and downloads are not wired yet, and the queue view (reordering) is not built, though the schema supports it. +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. ## Data model diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 7f24851c..0d6442f7 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -35,7 +35,8 @@ 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) 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. -- The queue view (add to queue / play next / reorder), and gapless format preferences. +- (done) A queue view that lists the resolved queue, jumps to a track on tap, removes tracks and reorders them by dragging. +- Adding to the queue from the library (play next / add to queue), and gapless format preferences. - Scrobbling and "now playing" back to the server. - Optional download/caching.