mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
shuffle an artist across all of their albums
This commit is contained in:
@@ -19,6 +19,14 @@ private const val ALBUM_SONGS_ORDER = " ORDER BY songs.disc, songs.track, songs.
|
|||||||
|
|
||||||
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
||||||
|
|
||||||
|
private const val ARTIST_SONGS_SELECT =
|
||||||
|
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND albums.artistId = :artistId"
|
||||||
|
|
||||||
|
private const val ARTIST_SONGS_ORDER =
|
||||||
|
" ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
||||||
|
|
||||||
private const val SONG_SQL =
|
private const val SONG_SQL =
|
||||||
"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 " +
|
||||||
@@ -72,6 +80,16 @@ interface QueueDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Long
|
): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT COUNT(*) FROM songs " +
|
||||||
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
|
"WHERE songs.sourceId = :sourceId AND albums.artistId = :artistId",
|
||||||
|
)
|
||||||
|
suspend fun artistLength(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
): Long
|
||||||
|
|
||||||
@Query("$PLAYLIST_SONGS_SQL LIMIT :limit OFFSET :offset")
|
@Query("$PLAYLIST_SONGS_SQL LIMIT :limit OFFSET :offset")
|
||||||
suspend fun playlistSongs(
|
suspend fun playlistSongs(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -145,6 +163,14 @@ interface QueueDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
|
@Query("$ARTIST_SONGS_SELECT$ARTIST_SONGS_ORDER LIMIT :limit OFFSET :offset")
|
||||||
|
suspend fun artistSongs(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
@Query("$SONG_SQL LIMIT :limit OFFSET :offset")
|
@Query("$SONG_SQL LIMIT :limit OFFSET :offset")
|
||||||
suspend fun song(
|
suspend fun song(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ data class DiscKey(
|
|||||||
val disc: Long,
|
val disc: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class QueueKind { Playlist, Album, Song }
|
enum class QueueKind { Playlist, Album, Song, Artist }
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "queue_entries",
|
tableName = "queue_entries",
|
||||||
|
|||||||
@@ -245,6 +245,11 @@ class QueueRepository(
|
|||||||
albumId: String,
|
albumId: String,
|
||||||
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Album, refId = albumId)
|
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Album, refId = albumId)
|
||||||
|
|
||||||
|
fun artistEntry(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Artist, refId = artistId)
|
||||||
|
|
||||||
fun songEntry(
|
fun songEntry(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
@@ -273,6 +278,14 @@ class QueueRepository(
|
|||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueueKind.Artist -> {
|
||||||
|
library
|
||||||
|
.artist(sourceId, refId)
|
||||||
|
.first()
|
||||||
|
?.name
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
// An explicit song entry in the middle of a queue is a reordered context track, so
|
// An explicit song entry in the middle of a queue is a reordered context track, so
|
||||||
// label it by its album rather than losing the context name.
|
// label it by its album rather than losing the context name.
|
||||||
QueueKind.Song -> {
|
QueueKind.Song -> {
|
||||||
@@ -632,6 +645,7 @@ class QueueRepository(
|
|||||||
QueueKind.Song -> listOf(ref.refId)
|
QueueKind.Song -> listOf(ref.refId)
|
||||||
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
|
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
|
||||||
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
|
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
|
||||||
|
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId)
|
||||||
}
|
}
|
||||||
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
|
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
|
||||||
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
|
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
|
||||||
@@ -731,6 +745,7 @@ class QueueRepository(
|
|||||||
when (entry.kind) {
|
when (entry.kind) {
|
||||||
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
|
QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
QueueKind.Song -> emptyList()
|
QueueKind.Song -> emptyList()
|
||||||
}
|
}
|
||||||
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
||||||
@@ -753,6 +768,7 @@ class QueueRepository(
|
|||||||
when (kind) {
|
when (kind) {
|
||||||
QueueKind.Album -> OrderKey(row.song.id, disc = row.song.disc, track = row.song.track)
|
QueueKind.Album -> OrderKey(row.song.id, disc = row.song.disc, track = row.song.track)
|
||||||
QueueKind.Playlist -> OrderKey(row.song.id, position = row.playlistPosition)
|
QueueKind.Playlist -> OrderKey(row.song.id, position = row.playlistPosition)
|
||||||
|
QueueKind.Artist -> OrderKey(row.song.id)
|
||||||
QueueKind.Song -> OrderKey(row.song.id)
|
QueueKind.Song -> OrderKey(row.song.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,6 +808,7 @@ class QueueRepository(
|
|||||||
when (entry.kind) {
|
when (entry.kind) {
|
||||||
QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||||
QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||||
|
QueueKind.Artist -> emptyList()
|
||||||
QueueKind.Song -> emptyList()
|
QueueKind.Song -> emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -804,6 +821,7 @@ class QueueRepository(
|
|||||||
when (entry.kind) {
|
when (entry.kind) {
|
||||||
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||||
QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||||
|
QueueKind.Artist -> emptyList()
|
||||||
QueueKind.Song -> emptyList()
|
QueueKind.Song -> emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -811,6 +829,7 @@ class QueueRepository(
|
|||||||
when (kind) {
|
when (kind) {
|
||||||
QueueKind.Playlist -> position != null
|
QueueKind.Playlist -> position != null
|
||||||
QueueKind.Album -> disc != null && track != null
|
QueueKind.Album -> disc != null && track != null
|
||||||
|
QueueKind.Artist -> false
|
||||||
QueueKind.Song -> false
|
QueueKind.Song -> false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -850,6 +869,10 @@ class QueueRepository(
|
|||||||
QueueKind.Album -> {
|
QueueKind.Album -> {
|
||||||
dao.albumLength(sourceId, refId)
|
dao.albumLength(sourceId, refId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QueueKind.Artist -> {
|
||||||
|
dao.artistLength(sourceId, refId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val available = (total - offset).coerceAtLeast(0)
|
val available = (total - offset).coerceAtLeast(0)
|
||||||
return count?.coerceAtMost(available) ?: available
|
return count?.coerceAtMost(available) ?: available
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ class PlaybackController(
|
|||||||
playlistId: String,
|
playlistId: String,
|
||||||
) = shuffleContext(queueRepository.playlistEntry(sourceId, playlistId))
|
) = shuffleContext(queueRepository.playlistEntry(sourceId, playlistId))
|
||||||
|
|
||||||
|
fun shuffleArtist(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
||||||
|
|
||||||
private fun playContext(
|
private fun playContext(
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
start: Long,
|
start: Long,
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ fun ArtistDetailRoute(
|
|||||||
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
|
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
|
||||||
starred = artist?.starred != null,
|
starred = artist?.starred != null,
|
||||||
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
|
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
|
||||||
|
onShuffle = viewModel::shuffle,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -104,4 +104,10 @@ class ArtistDetailViewModel(
|
|||||||
coverArt: String?,
|
coverArt: String?,
|
||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
|
fun shuffle() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
playbackController.shuffleArtist(sourceId.first(), artistId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -497,6 +497,7 @@ private fun QueueKind.label(): String =
|
|||||||
QueueKind.Album -> "album"
|
QueueKind.Album -> "album"
|
||||||
QueueKind.Playlist -> "playlist"
|
QueueKind.Playlist -> "playlist"
|
||||||
QueueKind.Song -> "song"
|
QueueKind.Song -> "song"
|
||||||
|
QueueKind.Artist -> "artist"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTime(milliseconds: Long): String {
|
private fun formatTime(milliseconds: Long): String {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.subtracks.data.db.SubtracksDatabase
|
|||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.download.FakeDownloadEngine
|
import com.subtracks.data.download.FakeDownloadEngine
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.DownloadStatus
|
import com.subtracks.data.model.DownloadStatus
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
@@ -2259,6 +2260,28 @@ class PlaybackControllerTest {
|
|||||||
assertTrue(controller.state.value.shuffle)
|
assertTrue(controller.state.value.shuffle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shuffleArtistPlaysAcrossAllOfTheArtistsAlbums() {
|
||||||
|
seedArtist()
|
||||||
|
|
||||||
|
controller.shuffleArtist(1, "ar1")
|
||||||
|
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||||
|
|
||||||
|
assertTrue(controller.state.value.shuffle)
|
||||||
|
assertEquals(
|
||||||
|
QueueKind.Artist,
|
||||||
|
controller.state.value.context
|
||||||
|
?.kind,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"ar1",
|
||||||
|
controller.state.value.context
|
||||||
|
?.refId,
|
||||||
|
)
|
||||||
|
assertEquals("Artist", runBlocking { controller.sourceTitle(controller.state.value.context) })
|
||||||
|
assertEquals(setOf("a1", "a2", "a3", "b1", "b2"), handle.items.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun restoringResumesFromTheSavedPosition() {
|
fun restoringResumesFromTheSavedPosition() {
|
||||||
seedAlbum(3, sourceId = 1)
|
seedAlbum(3, sourceId = 1)
|
||||||
@@ -2596,6 +2619,27 @@ class PlaybackControllerTest {
|
|||||||
assertTrue(handle.items.isNotEmpty())
|
assertTrue(handle.items.isNotEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun offlineShuffleArtistPlaysOnlyDownloadedTracks() {
|
||||||
|
seedArtist()
|
||||||
|
markDownloaded(1, "a1")
|
||||||
|
markDownloaded(1, "b2")
|
||||||
|
setOffline()
|
||||||
|
|
||||||
|
controller.shuffleArtist(1, "ar1")
|
||||||
|
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id in listOf("a1", "b2")
|
||||||
|
}
|
||||||
|
assertEquals(setOf("a1", "b2"), handle.items.map { it.id }.toSet())
|
||||||
|
assertEquals(
|
||||||
|
QueueKind.Artist,
|
||||||
|
controller.state.value.context
|
||||||
|
?.kind,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun offlinePlayAlbumFiltersTheWindowToDownloadedSongs() {
|
fun offlinePlayAlbumFiltersTheWindowToDownloadedSongs() {
|
||||||
seedAlbum(4, sourceId = 1)
|
seedAlbum(4, sourceId = 1)
|
||||||
@@ -2885,6 +2929,52 @@ class PlaybackControllerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun seedArtist() {
|
||||||
|
runBlocking {
|
||||||
|
db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1))
|
||||||
|
db.libraryDao().upsertArtists(listOf(Artist(sourceId = 1, id = "ar1", name = "Artist", albumCount = 2, starred = null)))
|
||||||
|
db.libraryDao().upsertAlbums(
|
||||||
|
listOf(
|
||||||
|
Album(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
name = "First",
|
||||||
|
albumArtist = "Artist",
|
||||||
|
created = 0,
|
||||||
|
coverArt = null,
|
||||||
|
genre = null,
|
||||||
|
year = 2001,
|
||||||
|
starred = null,
|
||||||
|
songCount = 3,
|
||||||
|
),
|
||||||
|
Album(
|
||||||
|
sourceId = 1,
|
||||||
|
id = "al2",
|
||||||
|
artistId = "ar1",
|
||||||
|
name = "Second",
|
||||||
|
albumArtist = "Artist",
|
||||||
|
created = 0,
|
||||||
|
coverArt = null,
|
||||||
|
genre = null,
|
||||||
|
year = 2002,
|
||||||
|
starred = null,
|
||||||
|
songCount = 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
Song(1, "a1", "al1", "ar1", "A1", "First", "Artist", 100, 1, 1, null, null),
|
||||||
|
Song(1, "a2", "al1", "ar1", "A2", "First", "Artist", 100, 2, 1, null, null),
|
||||||
|
Song(1, "a3", "al1", "ar1", "A3", "First", "Artist", 100, 3, 1, null, null),
|
||||||
|
Song(1, "b1", "al2", "ar1", "B1", "Second", "Artist", 100, 1, 1, null, null),
|
||||||
|
Song(1, "b2", "al2", "ar1", "B2", "Second", "Artist", 100, 2, 1, null, null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun seedAlbumTracks(
|
private fun seedAlbumTracks(
|
||||||
albumId: String,
|
albumId: String,
|
||||||
count: Int,
|
count: Int,
|
||||||
|
|||||||
Reference in New Issue
Block a user