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 " +
"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
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
interface LibraryDao {
@@ -93,12 +100,7 @@ interface LibraryDao {
@Query("SELECT * FROM albums WHERE sourceId = :sourceId ORDER BY created DESC, name COLLATE NOCASE")
fun albumsByRecentlyAdded(sourceId: Long): PagingSource<Int, Album>
@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 " +
"ORDER BY songs.title COLLATE NOCASE, songs.artist COLLATE NOCASE",
)
@Query(SONGS_SQL)
fun songs(sourceId: Long): PagingSource<Int, SongListItem>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE")
@@ -47,6 +47,9 @@ interface QueueDao {
albumId: String,
): 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")
suspend fun songLength(
sourceId: Long,
@@ -67,6 +70,12 @@ interface QueueDao {
offset: Long,
): SongListItem?
@Query("$SONGS_SQL LIMIT 1 OFFSET :offset")
suspend fun songAt(
sourceId: Long,
offset: Long,
): SongListItem?
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
@@ -175,7 +175,7 @@ data class SearchIndex(
val title: String,
)
enum class QueueKind { Playlist, Album, Song }
enum class QueueKind { Playlist, Album, Song, Songs }
@Entity(
tableName = "queue_entries",
@@ -53,6 +53,8 @@ class QueueRepository(
songId: String,
) = 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>) {
db.useWriterConnection { transactor ->
transactor.immediateTransaction {
@@ -99,6 +101,7 @@ class QueueRepository(
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)
}
}
@@ -108,6 +111,7 @@ class QueueRepository(
QueueKind.Song -> dao.songLength(sourceId, refId)
QueueKind.Playlist -> dao.playlistLength(sourceId, refId)
QueueKind.Album -> dao.albumLength(sourceId, refId)
QueueKind.Songs -> dao.songsLength(sourceId)
}
val available = (total - offset).coerceAtLeast(0)
return count?.coerceAtMost(available) ?: available
@@ -34,7 +34,7 @@ fun appModule(
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
single { PlaybackController(get(), get(), get()) }
viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get()) }
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get()) }
viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) }
@@ -118,6 +118,11 @@ class PlaybackController(
startPosition: Long,
) = play(listOf(queueRepository.playlistEntry(sourceId, playlistId)), startPosition)
fun playSongs(
sourceId: Long,
startPosition: Long,
) = play(listOf(queueRepository.songsEntry(sourceId)), startPosition)
fun togglePlayPause() {
val player = player ?: return
if (player.isEnded) {
@@ -93,6 +93,7 @@ fun LibraryRoute(
) {
var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Albums) }
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
val playingSongId by viewModel.playingSongId.collectAsStateWithLifecycle()
LibraryScreen(
selectedTab = selectedTab,
onTabSelected = { selectedTab = it },
@@ -105,8 +106,10 @@ fun LibraryRoute(
coverArt = viewModel::coverArt,
onAlbumClick = onAlbumClick,
onPlaylistClick = onPlaylistClick,
onSongClick = viewModel::playSong,
onSync = viewModel::sync,
onOpenSettings = onOpenSettings,
playingSongId = playingSongId,
)
}
@@ -122,9 +125,11 @@ fun LibraryScreen(
coverArt: (String?, Boolean) -> CoverArtRef?,
onAlbumClick: (Album) -> Unit,
onPlaylistClick: (Playlist) -> Unit,
onSongClick: (Int) -> Unit,
onSync: () -> Unit,
onOpenSettings: () -> Unit,
syncing: Boolean = false,
playingSongId: String? = null,
bottomInset: Dp = 0.dp,
modifier: Modifier = Modifier,
) {
@@ -206,7 +211,7 @@ fun LibraryScreen(
when (LibraryTab.entries[page]) {
LibraryTab.Albums -> AlbumsContent(albums, coverArt, bottomInset, onAlbumClick)
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)
}
}
@@ -8,12 +8,14 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongListItem
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager
import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
@@ -22,12 +24,14 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class LibraryViewModel(
private val libraryRepository: LibraryRepository,
private val sourceRepository: SourceRepository,
private val syncManager: SyncManager,
private val playbackController: PlaybackController,
userPreferences: UserPreferences,
) : ViewModel() {
val albums: Flow<PagingData<Album>> =
@@ -60,10 +64,22 @@ class LibraryViewModel(
.map { it == SyncStatus.Running }
.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(
coverArt: String?,
thumbnail: Boolean,
): 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()
}
@@ -1,5 +1,6 @@
package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -36,6 +37,8 @@ fun SongsContent(
items: LazyPagingItems<SongListItem>,
coverArt: (String?, Boolean) -> CoverArtRef?,
bottomInset: Dp,
onSongClick: (Int) -> Unit,
playingSongId: String? = null,
modifier: Modifier = Modifier,
) {
when {
@@ -55,7 +58,13 @@ fun SongsContent(
items(count = items.itemCount, key = items.itemKey { it.song.id }) { index ->
val item = items[index]
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.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
@@ -70,6 +71,19 @@ class QueueRepositoryTest {
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
fun windowSpansEntryBoundaries() =
runTest {
@@ -226,28 +240,55 @@ class QueueRepositoryTest {
db.sourcesDao().upsertSource(
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(
listOf(
song("s1", "al1", track = 1),
song("s2", "al1", track = 2),
song("s3", "al1", track = 3),
song("s4", "al2", track = 1),
song("s5", "al2", track = 2),
song("s1", "al1", track = 1, album = "First Album"),
song("s2", "al1", track = 2, album = "First Album"),
song("s3", "al1", track = 3, album = "First Album"),
song("s4", "al2", track = 1, album = "Second Album"),
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(
id: String,
albumId: String,
track: Long,
album: String = "Album",
) = Song(
sourceId = 1,
id = id,
albumId = albumId,
artistId = "ar1",
title = "Song $id",
album = "Album",
album = album,
artist = "Artist",
duration = 100,
track = track,
@@ -70,6 +70,19 @@ class PlaybackControllerTest {
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
fun advancingShiftsTheWindowAndKeepsItBounded() {
seedAlbum(100, sourceId = 1)
@@ -248,6 +248,7 @@ class AppScreenshotTest {
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onAlbumClick = {},
onPlaylistClick = {},
onSongClick = {},
onSync = {},
onOpenSettings = {},
)
@@ -50,6 +50,7 @@ class LibraryScreenTest {
coverArt = { _, _ -> null },
onAlbumClick = {},
onPlaylistClick = {},
onSongClick = {},
onSync = {},
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) 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).
- 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.
- Optional download/caching.