play the songs list from the songs tab

This commit is contained in:
austinried
2026-10-04 00:29:51 +00:00
parent 131b0d56a6
commit ebe76cd5bb
14 changed files with 124 additions and 17 deletions
@@ -21,6 +21,13 @@ internal const val PLAYLIST_SONGS_SQL =
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " + "WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
"ORDER BY playlist_songs.position" "ORDER BY playlist_songs.position"
internal const val SONGS_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 " +
"ORDER BY albums.albumArtist COLLATE NOCASE, songs.album COLLATE NOCASE, songs.disc, songs.track, " +
"songs.title COLLATE NOCASE, songs.id"
@Dao @Dao
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
interface LibraryDao { interface LibraryDao {
@@ -93,12 +100,7 @@ interface LibraryDao {
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE") @Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE")
fun albumsByRecentlyAdded(sourceId: Long): PagingSource<Int, Album> fun albumsByRecentlyAdded(sourceId: Long): PagingSource<Int, Album>
@Query( @Query(SONGS_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 " +
"ORDER BY songs.title COLLATE NOCASE, songs.artist COLLATE NOCASE",
)
fun songs(sourceId: Long): PagingSource<Int, SongListItem> fun songs(sourceId: Long): PagingSource<Int, SongListItem>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
@@ -47,6 +47,9 @@ interface QueueDao {
albumId: String, albumId: String,
): Long ): Long
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId")
suspend fun songsLength(sourceId: Long): Long
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND id = :songId") @Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND id = :songId")
suspend fun songLength( suspend fun songLength(
sourceId: Long, sourceId: Long,
@@ -67,6 +70,12 @@ interface QueueDao {
offset: Long, offset: Long,
): SongListItem? ): SongListItem?
@Query("$SONGS_SQL LIMIT 1 OFFSET :offset")
suspend fun songAt(
sourceId: Long,
offset: Long,
): SongListItem?
@Query( @Query(
"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 " +
@@ -175,7 +175,7 @@ data class SearchIndex(
val title: String, val title: String,
) )
enum class QueueKind { Playlist, Album, Song } enum class QueueKind { Playlist, Album, Song, Songs }
@Entity( @Entity(
tableName = "queue_entries", tableName = "queue_entries",
@@ -53,6 +53,8 @@ class QueueRepository(
songId: String, songId: String,
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Song, refId = songId) ) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Song, refId = songId)
fun songsEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Songs, refId = "")
suspend fun replace(entries: List<QueueEntry>) { suspend fun replace(entries: List<QueueEntry>) {
db.useWriterConnection { transactor -> db.useWriterConnection { transactor ->
transactor.immediateTransaction { transactor.immediateTransaction {
@@ -99,6 +101,7 @@ class QueueRepository(
QueueKind.Playlist -> dao.playlistSongAt(entry.sourceId, entry.refId, index) QueueKind.Playlist -> dao.playlistSongAt(entry.sourceId, entry.refId, index)
QueueKind.Album -> dao.albumSongAt(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.Song -> if (offset == 0L) dao.song(entry.sourceId, entry.refId) else null
QueueKind.Songs -> dao.songAt(entry.sourceId, index)
} }
} }
@@ -108,6 +111,7 @@ class QueueRepository(
QueueKind.Song -> dao.songLength(sourceId, refId) QueueKind.Song -> dao.songLength(sourceId, refId)
QueueKind.Playlist -> dao.playlistLength(sourceId, refId) QueueKind.Playlist -> dao.playlistLength(sourceId, refId)
QueueKind.Album -> dao.albumLength(sourceId, refId) QueueKind.Album -> dao.albumLength(sourceId, refId)
QueueKind.Songs -> dao.songsLength(sourceId)
} }
val available = (total - offset).coerceAtLeast(0) val available = (total - offset).coerceAtLeast(0)
return count?.coerceAtMost(available) ?: available return count?.coerceAtMost(available) ?: available
@@ -34,7 +34,7 @@ fun appModule(
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) } single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
single { PlaybackController(get(), get(), get()) } single { PlaybackController(get(), get(), get()) }
viewModel { RootViewModel(get()) } viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get()) } viewModel { SettingsViewModel(get(), get()) }
viewModel { AddSourceViewModel(get(), get()) } viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
@@ -118,6 +118,11 @@ class PlaybackController(
startPosition: Long, startPosition: Long,
) = play(listOf(queueRepository.playlistEntry(sourceId, playlistId)), startPosition) ) = play(listOf(queueRepository.playlistEntry(sourceId, playlistId)), startPosition)
fun playSongs(
sourceId: Long,
startPosition: Long,
) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition)
fun togglePlayPause() { fun togglePlayPause() {
val player = player ?: return val player = player ?: return
if (player.isEnded) { if (player.isEnded) {
@@ -93,6 +93,7 @@ fun LibraryRoute(
) { ) {
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) } var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
val syncing by viewModel.syncing.collectAsStateWithLifecycle() val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
LibraryScreen( LibraryScreen(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { selectedTab = it }, onTabSelected = { selectedTab = it },
@@ -105,8 +106,10 @@ fun LibraryRoute(
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onPlaylistClick = onPlaylistClick, onPlaylistClick = onPlaylistClick,
onSongClick = viewModel::playSong,
onSync = viewModel::sync, onSync = viewModel::sync,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
playingSongId = playingSongId,
) )
} }
@@ -122,9 +125,11 @@ fun LibraryScreen(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
onPlaylistClick: (Playlist) -> Unit, onPlaylistClick: (Playlist) -> Unit,
onSongClick: (Int) -> Unit,
onSync: () -> Unit, onSync: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
syncing: Boolean = false, syncing: Boolean = false,
playingSongId: String? = null,
bottomInset: Dp = 0.dp, bottomInset: Dp = 0.dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -206,7 +211,7 @@ fun LibraryScreen(
when (LibraryTab.entries[page]) { when (LibraryTab.entries[page]) {
LibraryTab.Albums -> AlbumsContent(albums, coverArt, bottomInset, onAlbumClick) LibraryTab.Albums -> AlbumsContent(albums, coverArt, bottomInset, onAlbumClick)
LibraryTab.Artists -> ArtistsContent(artists, coverArt, bottomInset) LibraryTab.Artists -> ArtistsContent(artists, coverArt, bottomInset)
LibraryTab.Songs -> SongsContent(songs, coverArt, bottomInset) LibraryTab.Songs -> SongsContent(songs, coverArt, bottomInset, onSongClick, playingSongId)
LibraryTab.Playlists -> PlaylistsContent(playlists, coverArt, bottomInset, onPlaylistClick) LibraryTab.Playlists -> PlaylistsContent(playlists, coverArt, bottomInset, onPlaylistClick)
} }
} }
@@ -8,12 +8,14 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongListItem import com.subtracks.data.model.SongListItem
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -22,12 +24,14 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class LibraryViewModel( class LibraryViewModel(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val syncManager: SyncManager, private val syncManager: SyncManager,
private val playbackController: PlaybackController,
userPreferences: UserPreferences, userPreferences: UserPreferences,
) : ViewModel() { ) : ViewModel() {
val albums: Flow<PagingData<Album>> = val albums: Flow<PagingData<Album>> =
@@ -60,10 +64,22 @@ class LibraryViewModel(
.map { it == SyncStatus.Running } .map { it == SyncStatus.Running }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val playingSongId: StateFlow<String?> =
playbackController.state
.map { state -> state.item?.id?.takeIf { state.context?.kind == QueueKind.Songs } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
fun coverArt( fun coverArt(
coverArt: String?, coverArt: String?,
thumbnail: Boolean, thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun playSong(position: Int) {
viewModelScope.launch {
val sourceId = sourceRepository.activeSourceIdOnce() ?: return@launch
playbackController.playSongs(sourceId, position.toLong())
}
}
fun sync() = syncManager.requestSync() fun sync() = syncManager.requestSync()
} }
@@ -1,5 +1,6 @@
package com.subtracks.ui.library package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -36,6 +37,8 @@ fun SongsContent(
items: LazyPagingItems<SongListItem>, items: LazyPagingItems<SongListItem>,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp, bottomInset: Dp,
onSongClick: (Int) -> Unit,
playingSongId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when { when {
@@ -55,7 +58,13 @@ fun SongsContent(
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index -> items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
val item = items[index] val item = items[index]
if (item != null) { if (item != null) {
SongRow(song = item.song, coverArtId = item.coverArt, coverArt = coverArt) SongRow(
song = item.song,
coverArtId = item.coverArt,
coverArt = coverArt,
isPlaying = item.song.id == playingSongId,
modifier = Modifier.clickable { onSongClick(index) },
)
} }
} }
} }
@@ -6,6 +6,7 @@ import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
@@ -70,6 +71,19 @@ class QueueRepositoryTest {
assertEquals(listOf("s2", "s3"), resolveAll(snapshot)) assertEquals(listOf("s2", "s3"), resolveAll(snapshot))
} }
@Test
fun theSongsEntryResolvesTheWholeLibraryInListOrder() =
runTest {
seedLibrary()
db.libraryDao().upsertAlbums(listOf(album("al0", "Zeroth Album", "Aardvark")))
db.libraryDao().upsertSongs(listOf(song("s0", "al0", track = 1, album = "Zeroth Album")))
val snapshot = repository.snapshotAfter(listOf(repository.songsEntry(1)))
assertEquals(6, snapshot.size)
assertEquals(listOf("s0", "s1", "s2", "s3", "s4", "s5"), resolveAll(snapshot))
}
@Test @Test
fun windowSpansEntryBoundaries() = fun windowSpansEntryBoundaries() =
runTest { runTest {
@@ -226,28 +240,55 @@ class QueueRepositoryTest {
db.sourcesDao().upsertSource( db.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0), Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
) )
db.libraryDao().upsertAlbums(
listOf(
album("al1", "First Album", "Artist One"),
album("al2", "Second Album", "Artist Two"),
),
)
db.libraryDao().upsertSongs( db.libraryDao().upsertSongs(
listOf( listOf(
song("s1", "al1", track = 1), song("s1", "al1", track = 1, album = "First Album"),
song("s2", "al1", track = 2), song("s2", "al1", track = 2, album = "First Album"),
song("s3", "al1", track = 3), song("s3", "al1", track = 3, album = "First Album"),
song("s4", "al2", track = 1), song("s4", "al2", track = 1, album = "Second Album"),
song("s5", "al2", track = 2), song("s5", "al2", track = 2, album = "Second Album"),
), ),
) )
} }
private fun album(
id: String,
name: String,
albumArtist: String,
) = Album(
sourceId = 1,
id = id,
artistId = "ar1",
name = name,
albumArtist = albumArtist,
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
frequentRank = null,
recentRank = null,
)
private fun song( private fun song(
id: String, id: String,
albumId: String, albumId: String,
track: Long, track: Long,
album: String = "Album",
) = Song( ) = Song(
sourceId = 1, sourceId = 1,
id = id, id = id,
albumId = albumId, albumId = albumId,
artistId = "ar1", artistId = "ar1",
title = "Song $id", title = "Song $id",
album = "Album", album = album,
artist = "Artist", artist = "Artist",
duration = 100, duration = 100,
track = track, track = track,
@@ -70,6 +70,19 @@ class PlaybackControllerTest {
assertEquals(listOf("prepare", "play"), handle.operations.takeLast(2)) assertEquals(listOf("prepare", "play"), handle.operations.takeLast(2))
} }
@Test
fun playingTheSongsListQueuesTheWholeLibrary() {
seedAlbum(60, sourceId = 1)
controller.playSongs(1, 30)
await { handle.operations.contains("play") }
assertEquals(51, handle.itemCount)
assertEquals(QueueContext(QueueKind.Songs, ""), controller.state.value.context)
assertTrue(controller.state.value.hasNext)
assertTrue(controller.state.value.hasPrevious)
}
@Test @Test
fun advancingShiftsTheWindowAndKeepsItBounded() { fun advancingShiftsTheWindowAndKeepsItBounded() {
seedAlbum(100, sourceId = 1) seedAlbum(100, sourceId = 1)
@@ -248,6 +248,7 @@ class AppScreenshotTest {
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onAlbumClick = {}, onAlbumClick = {},
onPlaylistClick = {}, onPlaylistClick = {},
onSongClick = {},
onSync = {}, onSync = {},
onOpenSettings = {}, onOpenSettings = {},
) )
@@ -50,6 +50,7 @@ class LibraryScreenTest {
coverArt = { _, _ -> null }, coverArt = { _, _ -> null },
onAlbumClick = {}, onAlbumClick = {},
onPlaylistClick = {}, onPlaylistClick = {},
onSongClick = {},
onSync = {}, onSync = {},
onOpenSettings = {}, onOpenSettings = {},
) )
+2 -1
View File
@@ -34,7 +34,8 @@ Not done yet in this slice (deliberately): artist detail, cover-art tonal colour
- (done) Media notifications and headset/Bluetooth controls, from the media session. - (done) Media notifications and headset/Bluetooth controls, from the media session.
- (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).
- Playing from the Songs tab, the queue view (add to queue / play next / reorder), and gapless format preferences. - (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.
- Scrobbling and "now playing" back to the server. - Scrobbling and "now playing" back to the server.
- Optional download/caching. - Optional download/caching.