mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
play the songs list from the songs tab
This commit is contained in:
@@ -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 = {},
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user