ui: split artist album credits from track appearances

Put the artist type and total album count on the single meta row beside the
star and more buttons, then list albums where the artist is an album artist
separately from albums where they only have a track credit. The hero shuffle
button and page-level actions stay where they were.

The split comes from an albumCredit flag on the shared credit rows, so the
online and downloaded album lists are partitioned by the same rule and the
two sections still sum to the stored album count.
This commit is contained in:
austinried
2026-10-19 02:57:56 +00:00
parent 824579c938
commit 8cdbe64942
12 changed files with 306 additions and 128 deletions
@@ -93,20 +93,21 @@ internal const val CREDITED_SONG_ROWS =
"WHERE album_artists.sourceId = :sourceId"
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe.
// albumCredit is 1 for album-level credits and 0 for track-level ones.
internal const val CREDITED_ALBUM_ROWS =
"SELECT albums.artistId AS artistId, albums.id AS albumId " +
"SELECT albums.artistId AS artistId, albums.id AS albumId, 1 AS albumCredit " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, albums.id " +
"SELECT album_artists.artistId, albums.id, 1 " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId " +
"UNION ALL " +
"SELECT songs.artistId, songs.albumId " +
"SELECT songs.artistId, songs.albumId, 0 " +
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.albumId " +
"SELECT song_artists.artistId, songs.albumId, 0 " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
@@ -1065,6 +1066,15 @@ interface LibraryDao {
artistId: String,
): Flow<List<Album>>
@Query(
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
"WHERE artistId = :artistId AND albumCredit = 1",
)
fun albumCreditAlbumIds(
sourceId: Long,
artistId: String,
): Flow<List<String>>
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
fun discs(
sourceId: Long,
@@ -167,6 +167,13 @@ data class Album(
}
}
data class ArtistAlbums(
val albumCredits: List<Album> = emptyList(),
val appearances: List<Album> = emptyList(),
) {
val count: Int get() = albumCredits.size + appearances.size
}
@Entity(
tableName = "discs",
primaryKeys = ["sourceId", "albumId", "disc"],
@@ -14,6 +14,7 @@ import com.subtracks.data.db.rediscoverAlbumsQuery
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.Disc
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSongItem
@@ -31,6 +32,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -376,12 +378,21 @@ class LibraryRepository(
sourceId: Long,
artistId: String,
downloaded: Boolean = false,
): Flow<List<Album>> =
if (downloaded) {
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
} else {
db.libraryDao().albumsForArtist(sourceId, artistId)
): Flow<ArtistAlbums> {
val albums =
if (downloaded) {
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
} else {
db.libraryDao().albumsForArtist(sourceId, artistId)
}
return combine(albums, db.libraryDao().albumCreditAlbumIds(sourceId, artistId)) { list, creditIds ->
val credits = creditIds.toHashSet()
ArtistAlbums(
albumCredits = list.filter { it.id in credits },
appearances = list.filterNot { it.id in credits },
)
}
}
fun albumDiscs(
sourceId: Long,
@@ -80,6 +80,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
@@ -140,11 +141,6 @@ private const val THEME_TRANSITION_MS = 100
private const val SCRIM_FADE_MS = 180
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
internal fun isAlbumArtist(
albums: List<Album>,
artistId: String,
): Boolean = albums.any { album -> album.artistId == artistId || album.artistRefs.any { it.id == artistId } }
@Composable
fun ArtistDetailRoute(
artistId: String,
@@ -192,8 +188,7 @@ fun ArtistDetailRoute(
ArtistDetailScreen(
artist = artist,
artistName = artistName.orEmpty(),
isAlbumArtist = albums?.let { isAlbumArtist(it, artistId) },
albums = albums.orEmpty(),
albums = albums ?: ArtistAlbums(),
albumsLoaded = albums != null,
art = art,
artThumbnail = artThumbnail,
@@ -225,8 +220,7 @@ fun ArtistDetailScreen(
artist: Artist?,
modifier: Modifier = Modifier,
artistName: String = "",
isAlbumArtist: Boolean? = null,
albums: List<Album>,
albums: ArtistAlbums = ArtistAlbums(),
albumsLoaded: Boolean = true,
art: CoverArtRef?,
artThumbnail: CoverArtRef? = null,
@@ -252,9 +246,19 @@ fun ArtistDetailScreen(
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
val albumCount = albums.size.toLong()
val albumCount = albums.count.toLong()
val displayName = artistName.ifBlank { artist?.name.orEmpty() }
val artistType = stringResource(if (isAlbumArtist == true) R.string.artist_type_album_artist else R.string.artist_type_artist)
val artistType =
stringResource(
if (albums.albumCredits.isNotEmpty()) R.string.artist_type_album_artist else R.string.artist_type_artist,
)
val albumCountText =
if (albumsLoaded) {
pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
} else {
""
}
val metaText = if (albumsLoaded) "$artistType · $albumCountText" else ""
var titleHeightPx by remember { mutableFloatStateOf(0f) }
val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight
val titleMaxLines =
@@ -386,24 +390,12 @@ fun ArtistDetailScreen(
Column(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET),
) {
Text(
text = if (albumsLoaded) artistType else "",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Hold the row at the button height even without the buttons, so the
// label-to-count gap is the same for every artist type.
Row(
modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text =
if (albumsLoaded) {
pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
} else {
""
},
text = metaText,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
@@ -435,49 +427,39 @@ fun ArtistDetailScreen(
}
}
}
itemsIndexed(albums, key = { _, album -> album.id }) { index, album ->
val art = coverArt(album.coverArt, false)
val thumbnail = coverArt(album.coverArt, true)
PrefetchArtworkSeeds(thumbnail)
Column(
modifier =
Modifier
.padding(
start = if (index % 2 == 0) 16.dp else 0.dp,
end = if (index % 2 == 1) 16.dp else 0.dp,
).combinedClickable(
onClick = { onAlbumClick(album) },
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id])) },
).onSizeChanged { albumHeightPx = it.height.toFloat() },
) {
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
) {
CoverArt(
ref = art,
name = album.name,
thumbnailRef = thumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxSize(),
)
ListDownloadIndicator(
status = albumDownloads[album.id],
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
scrim = true,
)
}
Text(
text = album.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
if (albums.albumCredits.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
ArtistSectionHeader(stringResource(R.string.artist_section_albums, albums.albumCredits.size))
}
itemsIndexed(albums.albumCredits, key = { _, album -> album.id }) { index, album ->
ArtistAlbumCard(
album = album,
index = index,
coverArt = coverArt,
downloadStatus = albumDownloads[album.id],
onClick = { onAlbumClick(album) },
onLongClick = { thumbnail ->
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
},
onSizeChanged = { albumHeightPx = it },
)
Text(
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
}
}
if (albums.appearances.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
ArtistSectionHeader(stringResource(R.string.artist_section_appearances, albums.appearances.size))
}
itemsIndexed(albums.appearances, key = { _, album -> album.id }) { index, album ->
ArtistAlbumCard(
album = album,
index = index,
coverArt = coverArt,
downloadStatus = albumDownloads[album.id],
onClick = { onAlbumClick(album) },
onLongClick = { thumbnail ->
onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
},
onSizeChanged = { albumHeightPx = it },
)
}
}
@@ -523,6 +505,72 @@ fun ArtistDetailScreen(
}
}
@Composable
private fun ArtistSectionHeader(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp),
)
}
@Composable
private fun ArtistAlbumCard(
album: Album,
index: Int,
coverArt: (String?, Boolean) -> CoverArtRef?,
downloadStatus: ListDownloadStatus?,
onClick: () -> Unit,
onLongClick: (CoverArtRef?) -> Unit,
onSizeChanged: (Float) -> Unit,
) {
val art = coverArt(album.coverArt, false)
val thumbnail = coverArt(album.coverArt, true)
PrefetchArtworkSeeds(thumbnail)
Column(
modifier =
Modifier
.padding(
start = if (index % 2 == 0) 16.dp else 0.dp,
end = if (index % 2 == 1) 16.dp else 0.dp,
).combinedClickable(
onClick = onClick,
onLongClick = { onLongClick(thumbnail) },
).onSizeChanged { onSizeChanged(it.height.toFloat()) },
) {
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
) {
CoverArt(
ref = art,
name = album.name,
thumbnailRef = thumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxSize(),
)
ListDownloadIndicator(
status = downloadStatus,
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
scrim = true,
)
}
Text(
text = album.name,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
Text(
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun ArtistTitle(
text: String,
@@ -2,8 +2,8 @@ package com.subtracks.ui.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList
@@ -50,10 +50,10 @@ class ArtistDetailViewModel(
val offline: StateFlow<Boolean> = sourceRepository.offline
val albums: Flow<List<Album>?> =
val albums: Flow<ArtistAlbums?> =
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
.map<List<Album>, List<Album>?> { it }
.map<ArtistAlbums, ArtistAlbums?> { it }
.onStart { emit(null) }
val downloadStatus: StateFlow<ListDownloadStatus> =
+2
View File
@@ -35,6 +35,8 @@
<string name="resources_filter_artist">Artist</string>
<string name="artist_type_artist">Artist</string>
<string name="artist_type_album_artist">Album artist</string>
<string name="artist_section_albums">Albums (%1$d)</string>
<string name="artist_section_appearances">Appears on (%1$d)</string>
<string name="resources_filter_genre">Genre</string>
<string name="resources_filter_starred">Starred</string>
<string name="resources_song_list_delete_all_content">%1$s\n\nDeleting will free %2$s</string>