From 336ff1a1d11187b260cae7c481e6f811bd40b6f0 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:44:44 +0000 Subject: [PATCH] shuffle an artist across all of their albums --- .../kotlin/com/subtracks/data/db/QueueDao.kt | 26 ++++++ .../kotlin/com/subtracks/data/model/Models.kt | 2 +- .../subtracks/data/repo/QueueRepository.kt | 23 +++++ .../subtracks/playback/PlaybackController.kt | 5 ++ .../ui/library/ArtistDetailScreen.kt | 1 + .../ui/library/ArtistDetailViewModel.kt | 6 ++ .../subtracks/ui/playback/NowPlayingScreen.kt | 1 + .../playback/PlaybackControllerTest.kt | 90 +++++++++++++++++++ 8 files changed, 153 insertions(+), 1 deletion(-) 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 789447a7..a867770f 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/QueueDao.kt @@ -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 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 = "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + @@ -72,6 +80,16 @@ interface QueueDao { songId: String, ): 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") suspend fun playlistSongs( sourceId: Long, @@ -145,6 +163,14 @@ interface QueueDao { limit: Int, ): List + @Query("$ARTIST_SONGS_SELECT$ARTIST_SONGS_ORDER LIMIT :limit OFFSET :offset") + suspend fun artistSongs( + sourceId: Long, + artistId: String, + offset: Long, + limit: Int, + ): List + @Query("$SONG_SQL LIMIT :limit OFFSET :offset") suspend fun song( sourceId: Long, 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 e5032b13..9aa31276 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Models.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Models.kt @@ -259,7 +259,7 @@ data class DiscKey( val disc: Long, ) -enum class QueueKind { Playlist, Album, Song } +enum class QueueKind { Playlist, Album, Song, Artist } @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 eca9a3e1..21d69399 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/QueueRepository.kt @@ -245,6 +245,11 @@ class QueueRepository( albumId: String, ) = 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( sourceId: Long, songId: String, @@ -273,6 +278,14 @@ class QueueRepository( ?.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 // label it by its album rather than losing the context name. QueueKind.Song -> { @@ -632,6 +645,7 @@ class QueueRepository( QueueKind.Song -> listOf(ref.refId) QueueKind.Album -> dao.albumSongIds(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 to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size) @@ -731,6 +745,7 @@ class QueueRepository( when (entry.kind) { QueueKind.Playlist -> dao.playlistSongs(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() } if (fresh.isNotEmpty()) remember(ref, offset, fresh) @@ -753,6 +768,7 @@ class QueueRepository( when (kind) { QueueKind.Album -> OrderKey(row.song.id, disc = row.song.disc, track = row.song.track) QueueKind.Playlist -> OrderKey(row.song.id, position = row.playlistPosition) + QueueKind.Artist -> OrderKey(row.song.id) QueueKind.Song -> OrderKey(row.song.id) } @@ -792,6 +808,7 @@ class QueueRepository( when (entry.kind) { 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.Artist -> emptyList() QueueKind.Song -> emptyList() } @@ -804,6 +821,7 @@ class QueueRepository( when (entry.kind) { 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.Artist -> emptyList() QueueKind.Song -> emptyList() } @@ -811,6 +829,7 @@ class QueueRepository( when (kind) { QueueKind.Playlist -> position != null QueueKind.Album -> disc != null && track != null + QueueKind.Artist -> false QueueKind.Song -> false } @@ -850,6 +869,10 @@ class QueueRepository( QueueKind.Album -> { dao.albumLength(sourceId, refId) } + + QueueKind.Artist -> { + dao.artistLength(sourceId, refId) + } } val available = (total - offset).coerceAtLeast(0) return count?.coerceAtMost(available) ?: available diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt index c1706c77..71c73a3d 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackController.kt @@ -238,6 +238,11 @@ class PlaybackController( playlistId: String, ) = shuffleContext(queueRepository.playlistEntry(sourceId, playlistId)) + fun shuffleArtist( + sourceId: Long, + artistId: String, + ) = shuffleContext(queueRepository.artistEntry(sourceId, artistId)) + private fun playContext( entry: QueueEntry, start: Long, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index 2921ad72..58c4ab73 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -190,6 +190,7 @@ fun ArtistDetailRoute( onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } }, starred = artist?.starred != null, onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } }, + onShuffle = viewModel::shuffle, offline = offline, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt index 84a32806..5edc2e10 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -104,4 +104,10 @@ class ArtistDetailViewModel( coverArt: String?, thumbnail: Boolean, ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) + + fun shuffle() { + viewModelScope.launch { + playbackController.shuffleArtist(sourceId.first(), artistId) + } + } } diff --git a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt index 9d80ba30..ade28379 100644 --- a/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/playback/NowPlayingScreen.kt @@ -497,6 +497,7 @@ private fun QueueKind.label(): String = QueueKind.Album -> "album" QueueKind.Playlist -> "playlist" QueueKind.Song -> "song" + QueueKind.Artist -> "artist" } private fun formatTime(milliseconds: Long): String { diff --git a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt index 3653a598..ef4dc7ea 100644 --- a/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt +++ b/app/src/test/kotlin/com/subtracks/playback/PlaybackControllerTest.kt @@ -10,6 +10,7 @@ import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.FakeDownloadEngine import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSong @@ -2259,6 +2260,28 @@ class PlaybackControllerTest { 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 fun restoringResumesFromTheSavedPosition() { seedAlbum(3, sourceId = 1) @@ -2596,6 +2619,27 @@ class PlaybackControllerTest { 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 fun offlinePlayAlbumFiltersTheWindowToDownloadedSongs() { 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( albumId: String, count: Int,