From ebe76cd5bb5e6ae227260fa15df40f4cc87c2e29 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:17:06 +0000 Subject: [PATCH] play the songs list from the songs tab --- .../com/subtracks/data/db/LibraryDao.kt | 14 ++--- .../kotlin/com/subtracks/data/db/QueueDao.kt | 9 ++++ .../kotlin/com/subtracks/data/model/Models.kt | 2 +- .../subtracks/data/repo/QueueRepository.kt | 4 ++ .../main/kotlin/com/subtracks/di/AppModule.kt | 2 +- .../subtracks/playback/PlaybackController.kt | 5 ++ .../com/subtracks/ui/library/LibraryScreen.kt | 7 ++- .../subtracks/ui/library/LibraryViewModel.kt | 16 ++++++ .../com/subtracks/ui/library/SongsContent.kt | 11 +++- .../data/repo/QueueRepositoryTest.kt | 53 ++++++++++++++++--- .../playback/PlaybackControllerTest.kt | 13 +++++ .../com/subtracks/ui/AppScreenshotTest.kt | 1 + .../com/subtracks/ui/LibraryScreenTest.kt | 1 + docs/implementation-plan.md | 3 +- 14 files changed, 124 insertions(+), 17 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 6c2630f6..acef4ec6 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -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 - @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 @Query("SELECT * FROM playlists WHERE sourceId = :sourceId ORDER BY name COLLATE NOCASE") 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 cb0f38f2..4cd51c4c 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -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 " + diff --git a/app/src/main/kotlin/com/subtracks/data/model/Models.kt b/app/src/main/kotlin/com/subtracks/data/model/Models.kt index 349d7681..37a598d0 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -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", 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 ce7039ef..faca4913 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -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) { 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 diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index ccb7d0a4..b1f44877 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -34,7 +34,7 @@ fun appModule( single { 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()) } diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index 184fdd00..beea1275 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -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) { diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index dcf0a157..20367600 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -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) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 45755730..1de10c05 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -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> = @@ -60,10 +64,22 @@ class LibraryViewModel( .map { it == SyncStatus.Running } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + val playingSongId: StateFlow = + 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() } 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 3d52a9e1..92349aed 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/SongsContent.kt @@ -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, 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) }, + ) } } } 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 50f13e4b..132b3eb6 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/QueueRepositoryTest.kt @@ -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, diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index a4282f1e..5da5bd6f 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -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) diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index e9c834b5..7507cc48 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -248,6 +248,7 @@ class AppScreenshotTest { coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, onAlbumClick = {}, onPlaylistClick = {}, + onSongClick = {}, onSync = {}, onOpenSettings = {}, ) diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt index 2933bcb2..9a80bdfd 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt @@ -50,6 +50,7 @@ class LibraryScreenTest { coverArt = { _, _ -> null }, onAlbumClick = {}, onPlaylistClick = {}, + onSongClick = {}, onSync = {}, onOpenSettings = {}, ) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index d58f55e0..ee7dd403 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -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.