group the downloads tree by the album's artist

The tree read the track artist off each song, so a downloaded album split into
one group per guest performer and every song without a track artist landed in
"Unknown artist". It reads the album's artist now, which is also the artist the
node's delete resolves through, so the two agree.

The badges are white glyphs with a dark outline and a progress ring with a
visible track, after a look at the rendered result; larger and clearer than the
first pass, which the art swallowed.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent 837ab9ac5d
commit dac56a7fcb
4 changed files with 90 additions and 18 deletions
@@ -99,11 +99,11 @@ interface DownloadDao {
@Query(
"SELECT d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
"s.artistId AS artistId, ar.name AS artistName, d.status AS status, d.total AS size " +
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, d.total AS size " +
"FROM song_downloads d " +
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " +
"WHERE d.sourceId = :sourceId",
)
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
@@ -12,6 +12,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@@ -38,36 +39,42 @@ fun ListDownloadIndicator(
val current = status ?: return
val kind = current.indicator()
if (kind == DownloadIndicator.Hidden) return
Box(modifier = modifier.size(20.dp), contentAlignment = Alignment.Center) {
Box(modifier = modifier.size(26.dp), contentAlignment = Alignment.Center) {
when (kind) {
DownloadIndicator.Complete -> {
Icon(
imageVector = Icons.Rounded.DownloadDone,
contentDescription = null,
tint = ICON_SHADOW,
modifier = Modifier.size(18.dp).blur(3.dp).offset(y = 1.dp),
tint = Color.Black.copy(alpha = 0.5f),
modifier = Modifier.size(22.dp).blur(4.dp).offset(y = 1.dp),
)
Icon(
imageVector = Icons.Rounded.DownloadDone,
contentDescription = null,
tint = Color.Black.copy(alpha = 0.85f),
modifier =
Modifier
.size(22.dp)
.graphicsLayer {
scaleX = 1.18f
scaleY = 1.18f
}.blur(1.dp),
)
Icon(
imageVector = Icons.Rounded.DownloadDone,
contentDescription = DOWNLOADED_INDICATOR_TAG,
tint = Color.White,
modifier = Modifier.size(18.dp),
modifier = Modifier.size(22.dp),
)
}
DownloadIndicator.InProgress -> {
val progress = { current.downloaded.toFloat() / current.total }
CircularProgressIndicator(
progress = progress,
color = ICON_SHADOW,
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp).blur(3.dp).offset(y = 1.dp),
)
CircularProgressIndicator(
progress = progress,
progress = { current.downloaded.toFloat() / current.total },
color = Color.White,
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp).semantics { contentDescription = DOWNLOADING_INDICATOR_TAG },
trackColor = Color.Black.copy(alpha = 0.45f),
strokeWidth = 2.5.dp,
modifier = Modifier.size(20.dp).semantics { contentDescription = DOWNLOADING_INDICATOR_TAG },
)
}
@@ -77,5 +84,3 @@ fun ListDownloadIndicator(
}
}
}
private val ICON_SHADOW = Color.Black.copy(alpha = 0.45f)
@@ -816,6 +816,41 @@ class DownloadRepositoryTest {
assertFalse(file(1, "s2").exists())
}
@Test
fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertSongs(
listOf(
Song(
sourceId = 1,
id = "s3",
albumId = "al1",
artistId = "ar-guest",
title = "Song 3",
album = "Album",
artist = "Guest",
duration = 100,
track = 3,
disc = 1,
starred = null,
genre = null,
),
),
)
db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar-guest", name = "Guest", albumCount = 0, starred = null, coverArt = null)),
)
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Completed))
}
val songs = runBlocking { repository.downloadedSongs(1).first() }
assertEquals(setOf("ar1" to "Artist"), songs.map { it.artistId to it.artistName }.toSet())
}
private fun requestedSongIds(): List<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
private fun seedSongs(tracks: IntRange) {
@@ -32,6 +32,7 @@ import com.github.takahirom.roborazzi.captureRoboImage
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.Song
@@ -125,6 +126,37 @@ class AppScreenshotTest {
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_scrolled_under_header.png")
}
@Test
fun libraryAlbumsWithDownloads() {
val ids = Fixtures.albums.take(3).map { it.id }
composeRule.setContent {
SubtracksTheme {
LibraryScreen(
selectedTab = LibraryTab.Albums,
onTabSelected = {},
albums = remember { flowOf(PagingData.from(Fixtures.albums)) },
artists = remember { flowOf(PagingData.empty<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
albumDownloads =
mapOf(
ids[0] to ListDownloadStatus(total = 10, downloaded = 10),
ids[1] to ListDownloadStatus(total = 10, downloaded = 4, downloading = 1),
ids[2] to ListDownloadStatus(total = 10, downloaded = 4),
),
)
}
}
awaitTag(ALBUM_COVER_TAG)
composeRule.onRoot().captureRoboImage("src/test/screenshots/library_albums_downloads.png")
}
@Test
fun libraryArtists() {
setLibraryContent(LibraryTab.Artists)