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" "WHERE album_artists.sourceId = :sourceId"
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe. // 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 = 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 " + "FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " + "UNION ALL " +
"SELECT album_artists.artistId, albums.id " + "SELECT album_artists.artistId, albums.id, 1 " +
"FROM album_artists JOIN albums " + "FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " + "ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId " + "WHERE album_artists.sourceId = :sourceId " +
"UNION ALL " + "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 " + "FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " + "WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
"UNION ALL " + "UNION ALL " +
"SELECT song_artists.artistId, songs.albumId " + "SELECT song_artists.artistId, songs.albumId, 0 " +
"FROM song_artists JOIN songs " + "FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " + "ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
@@ -1065,6 +1066,15 @@ interface LibraryDao {
artistId: String, artistId: String,
): Flow<List<Album>> ): 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") @Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
fun discs( fun discs(
sourceId: Long, 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( @Entity(
tableName = "discs", tableName = "discs",
primaryKeys = ["sourceId", "albumId", "disc"], 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.Album
import com.subtracks.data.model.AlbumSongItem import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.Disc import com.subtracks.data.model.Disc
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSongItem import com.subtracks.data.model.PlaylistSongItem
@@ -31,6 +32,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -376,12 +378,21 @@ class LibraryRepository(
sourceId: Long, sourceId: Long,
artistId: String, artistId: String,
downloaded: Boolean = false, downloaded: Boolean = false,
): Flow<List<Album>> = ): Flow<ArtistAlbums> {
if (downloaded) { val albums =
db.libraryDao().albumsForArtistDownloaded(sourceId, artistId) if (downloaded) {
} else { db.libraryDao().albumsForArtistDownloaded(sourceId, artistId)
db.libraryDao().albumsForArtist(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( fun albumDiscs(
sourceId: Long, sourceId: Long,
@@ -80,6 +80,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.R import com.subtracks.R
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
@@ -140,11 +141,6 @@ private const val THEME_TRANSITION_MS = 100
private const val SCRIM_FADE_MS = 180 private const val SCRIM_FADE_MS = 180
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim" 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 @Composable
fun ArtistDetailRoute( fun ArtistDetailRoute(
artistId: String, artistId: String,
@@ -192,8 +188,7 @@ fun ArtistDetailRoute(
ArtistDetailScreen( ArtistDetailScreen(
artist = artist, artist = artist,
artistName = artistName.orEmpty(), artistName = artistName.orEmpty(),
isAlbumArtist = albums?.let { isAlbumArtist(it, artistId) }, albums = albums ?: ArtistAlbums(),
albums = albums.orEmpty(),
albumsLoaded = albums != null, albumsLoaded = albums != null,
art = art, art = art,
artThumbnail = artThumbnail, artThumbnail = artThumbnail,
@@ -225,8 +220,7 @@ fun ArtistDetailScreen(
artist: Artist?, artist: Artist?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
artistName: String = "", artistName: String = "",
isAlbumArtist: Boolean? = null, albums: ArtistAlbums = ArtistAlbums(),
albums: List<Album>,
albumsLoaded: Boolean = true, albumsLoaded: Boolean = true,
art: CoverArtRef?, art: CoverArtRef?,
artThumbnail: CoverArtRef? = null, artThumbnail: CoverArtRef? = null,
@@ -252,9 +246,19 @@ fun ArtistDetailScreen(
val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() } val statusBarTop = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() } val navBarBottom = with(density) { WindowInsets.navigationBars.getBottom(this).toDp() }
val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight val barHeight = statusBarTop + TopAppBarDefaults.TopAppBarExpandedHeight
val albumCount = albums.size.toLong() val albumCount = albums.count.toLong()
val displayName = artistName.ifBlank { artist?.name.orEmpty() } 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) } var titleHeightPx by remember { mutableFloatStateOf(0f) }
val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight val titleHeight = if (titleHeightPx > 0f) with(density) { titleHeightPx.toDp() } else lineHeight
val titleMaxLines = val titleMaxLines =
@@ -386,24 +390,12 @@ fun ArtistDetailScreen(
Column( Column(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = TITLE_INSET), 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( Row(
modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp), modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = text = metaText,
if (albumsLoaded) {
pluralStringResource(R.plurals.resources_album_count, albumCount.toInt(), albumCount)
} else {
""
},
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -435,49 +427,39 @@ fun ArtistDetailScreen(
} }
} }
} }
itemsIndexed(albums, key = { _, album -> album.id }) { index, album -> if (albums.albumCredits.isNotEmpty()) {
val art = coverArt(album.coverArt, false) item(span = { GridItemSpan(maxLineSpan) }) {
val thumbnail = coverArt(album.coverArt, true) ArtistSectionHeader(stringResource(R.string.artist_section_albums, albums.albumCredits.size))
PrefetchArtworkSeeds(thumbnail) }
Column( itemsIndexed(albums.albumCredits, key = { _, album -> album.id }) { index, album ->
modifier = ArtistAlbumCard(
Modifier album = album,
.padding( index = index,
start = if (index % 2 == 0) 16.dp else 0.dp, coverArt = coverArt,
end = if (index % 2 == 1) 16.dp else 0.dp, downloadStatus = albumDownloads[album.id],
).combinedClickable( onClick = { onAlbumClick(album) },
onClick = { onAlbumClick(album) }, onLongClick = { thumbnail ->
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id])) }, onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id]))
).onSizeChanged { albumHeightPx = it.height.toFloat() }, },
) { onSizeChanged = { albumHeightPx = it },
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),
) )
Text( }
text = album.year?.takeIf { it > 0 }?.toString() ?: "\u00A0", }
style = MaterialTheme.typography.bodySmall, if (albums.appearances.isNotEmpty()) {
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 @Composable
private fun ArtistTitle( private fun ArtistTitle(
text: String, text: String,
@@ -2,8 +2,8 @@ package com.subtracks.ui.library
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadList
@@ -50,10 +50,10 @@ class ArtistDetailViewModel(
val offline: StateFlow<Boolean> = sourceRepository.offline val offline: StateFlow<Boolean> = sourceRepository.offline
val albums: Flow<List<Album>?> = val albums: Flow<ArtistAlbums?> =
combine(sourceId, sourceRepository.offline) { id, offline -> id to offline } combine(sourceId, sourceRepository.offline) { id, offline -> id to offline }
.flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) } .flatMapLatest { (id, offline) -> libraryRepository.artistAlbums(id, artistId, offline) }
.map<List<Album>, List<Album>?> { it } .map<ArtistAlbums, ArtistAlbums?> { it }
.onStart { emit(null) } .onStart { emit(null) }
val downloadStatus: StateFlow<ListDownloadStatus> = val downloadStatus: StateFlow<ListDownloadStatus> =
+2
View File
@@ -35,6 +35,8 @@
<string name="resources_filter_artist">Artist</string> <string name="resources_filter_artist">Artist</string>
<string name="artist_type_artist">Artist</string> <string name="artist_type_artist">Artist</string>
<string name="artist_type_album_artist">Album 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_genre">Genre</string>
<string name="resources_filter_starred">Starred</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> <string name="resources_song_list_delete_all_content">%1$s\n\nDeleting will free %2$s</string>
@@ -515,6 +515,54 @@ class LibraryDaoTest {
assertEquals(listOf("al-1"), dao.albumsForArtistDownloaded(sourceId, "ar-guest").first().map { it.id }) assertEquals(listOf("al-1"), dao.albumsForArtistDownloaded(sourceId, "ar-guest").first().map { it.id })
} }
@Test
fun albumCreditsAreSeparatedFromTrackAppearances() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Own", year = null, starred = null),
album(sourceId, "al-2", "Other", year = null, starred = null).copy(artistId = "ar-2"),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", starred = null),
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-1"),
),
)
assertEquals(listOf("al-1"), dao.albumCreditAlbumIds(sourceId, "ar-1").first())
assertEquals(listOf("al-2"), dao.albumCreditAlbumIds(sourceId, "ar-2").first())
assertEquals(
listOf("al-1", "al-2"),
dao
.albumsForArtist(sourceId, "ar-1")
.first()
.map { it.id }
.sorted(),
)
}
@Test
fun albumCreditIdsFindCreditsThroughArtistIndexes() =
runTest {
val plan =
plan(
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
"WHERE artistId = :artistId AND albumCredit = 1",
)
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertFalse(plan, plan.contains("SCAN albums"))
assertFalse(plan, plan.contains("SCAN songs"))
}
@Test @Test
fun downloadedArtistsIncludeOneCreditedOnlyOnADownloadedTrack() = fun downloadedArtistsIncludeOneCreditedOnlyOnADownloadedTrack() =
runTest { runTest {
@@ -13,6 +13,7 @@ import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.StarredFilter import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
@@ -203,6 +204,33 @@ class LibraryRepositoryTest {
} }
} }
@Test
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
runBlocking {
val sourceId = 1L
db.sourcesDao().upsertSource(
Source(id = sourceId, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
)
db.libraryDao().upsertAlbums(
listOf(
seedAlbum(sourceId, 1),
seedAlbum(sourceId, 2).copy(artistId = "ar2"),
),
)
db.libraryDao().upsertSongs(
listOf(
seedSong(sourceId).copy(albumId = "al-0001"),
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
),
)
val albums = repository.artistAlbums(sourceId, "ar1").first()
assertEquals(listOf("al-0001"), albums.albumCredits.map { it.id })
assertEquals(listOf("al-0002"), albums.appearances.map { it.id })
assertEquals(2, albums.count)
}
private suspend fun awaitRefreshes( private suspend fun awaitRefreshes(
presenter: RecordingPresenter, presenter: RecordingPresenter,
target: Int, target: Int,
@@ -37,6 +37,7 @@ import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureRoboImage import com.github.takahirom.roborazzi.captureRoboImage
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
@@ -306,7 +307,10 @@ class AppScreenshotTest {
SubtracksTheme { SubtracksTheme {
ArtistDetailScreen( ArtistDetailScreen(
artist = Fixtures.artists.first(), artist = Fixtures.artists.first(),
albums = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id }, albums =
ArtistAlbums(
albumCredits = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id },
),
art = CoverArtRef("art-ar-radiohead", "test:art-ar-radiohead"), art = CoverArtRef("art-ar-radiohead", "test:art-ar-radiohead"),
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)), artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
@@ -32,6 +32,7 @@ import com.github.takahirom.roborazzi.captureRoboImage
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
@@ -177,8 +178,19 @@ class DemoScreenshotTest {
ArtistDetailScreen( ArtistDetailScreen(
artist = demo.artist, artist = demo.artist,
artistName = demo.artist.name, artistName = demo.artist.name,
isAlbumArtist = demo.artistAlbums.any { album -> album.artistRefs.any { it.id == demo.artist.id } }, albums =
albums = demo.artistAlbums, ArtistAlbums(
albumCredits =
demo.artistAlbums.filter { album ->
album.artistId == demo.artist.id ||
album.artistRefs.any { it.id == demo.artist.id }
},
appearances =
demo.artistAlbums.filterNot { album ->
album.artistId == demo.artist.id ||
album.artistRefs.any { it.id == demo.artist.id }
},
),
art = demo.coverArt(demo.artist.coverArt, false), art = demo.coverArt(demo.artist.coverArt, false),
artThumbnail = demo.coverArt(demo.artist.coverArt, true), artThumbnail = demo.coverArt(demo.artist.coverArt, true),
artwork = demo.artwork(demo.artist.coverArt), artwork = demo.artwork(demo.artist.coverArt),
@@ -1,40 +0,0 @@
package com.subtracks.ui.library
import com.subtracks.data.model.Album
import com.subtracks.data.model.ArtistRef
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AlbumArtistLabelTest {
@Test
fun creditsBothTheSingularIdAndEveryPluralRef() {
val album =
album(
artistId = "a1",
artists = listOf(ArtistRef("a2", "B")),
)
assertTrue(isAlbumArtist(listOf(album), "a1"))
assertTrue(isAlbumArtist(listOf(album), "a2"))
assertFalse(isAlbumArtist(listOf(album), "a3"))
}
private fun album(
artistId: String?,
artists: List<ArtistRef>,
) = Album(
sourceId = 1,
id = "al1",
artistId = artistId,
name = "Album",
albumArtist = "Various Artists",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
artists = artists,
)
}
@@ -2,13 +2,18 @@ package com.subtracks.ui.library
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistAlbums
import com.subtracks.ui.theme.SubtracksTheme import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -24,7 +29,7 @@ class ArtistDetailScreenTest {
private fun render( private fun render(
artist: Artist?, artist: Artist?,
artistName: String, artistName: String,
isAlbumArtist: Boolean? = null, albums: ArtistAlbums = ArtistAlbums(),
albumsLoaded: Boolean = true, albumsLoaded: Boolean = true,
) { ) {
composeRule.setContent { composeRule.setContent {
@@ -32,8 +37,7 @@ class ArtistDetailScreenTest {
ArtistDetailScreen( ArtistDetailScreen(
artist = artist, artist = artist,
artistName = artistName, artistName = artistName,
isAlbumArtist = isAlbumArtist, albums = albums,
albums = emptyList(),
albumsLoaded = albumsLoaded, albumsLoaded = albumsLoaded,
art = null, art = null,
coverArt = { _, _ -> null }, coverArt = { _, _ -> null },
@@ -46,10 +50,14 @@ class ArtistDetailScreenTest {
@Test @Test
fun aCreditedTrackArtistShowsItsDerivedNameAndIsLabelledArtist() { fun aCreditedTrackArtistShowsItsDerivedNameAndIsLabelledArtist() {
render(artist = null, artistName = "Guest", isAlbumArtist = false) render(
artist = null,
artistName = "Guest",
albums = ArtistAlbums(appearances = listOf(album("al-1", "Guest Album"))),
)
composeRule.onAllNodesWithText("Guest")[0].assertExists() composeRule.onAllNodesWithText("Guest")[0].assertExists()
composeRule.onNodeWithText("Artist").assertExists() composeRule.onNodeWithText("Artist · 1 album").assertExists()
composeRule.onAllNodesWithContentDescription("More options").assertCountEquals(0) composeRule.onAllNodesWithContentDescription("More options").assertCountEquals(0)
} }
@@ -58,13 +66,36 @@ class ArtistDetailScreenTest {
render( render(
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null), artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 1, starred = null),
artistName = "Radiohead", artistName = "Radiohead",
isAlbumArtist = true, albums = ArtistAlbums(albumCredits = listOf(album("al-1", "Kid A"))),
) )
composeRule.onNodeWithText("Album artist").assertExists() composeRule.onNodeWithText("Album artist · 1 album").assertExists()
composeRule.onNodeWithContentDescription("More options").assertExists() composeRule.onNodeWithContentDescription("More options").assertExists()
} }
@Test
fun mixedCreditsShowAlbumCreditsAndAppearancesInSeparateSections() {
render(
artist = Artist(sourceId = 1, id = "ar-1", name = "Radiohead", albumCount = 2, starred = null),
artistName = "Radiohead",
albums =
ArtistAlbums(
albumCredits = listOf(album("al-1", "Kid A")),
appearances = listOf(album("al-2", "Guest Album")),
),
)
composeRule.onNodeWithText("Album artist · 2 albums").assertExists()
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Albums (1)"))
composeRule.onNodeWithText("Albums (1)").assertExists()
composeRule.onNodeWithText("Kid A").assertExists()
composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Appears on (1)"))
composeRule.onNodeWithText("Appears on (1)").assertExists()
composeRule.onNodeWithText("Guest Album").assertExists()
}
@Test @Test
fun theTypeAndCountStayBlankUntilTheAlbumsLoad() { fun theTypeAndCountStayBlankUntilTheAlbumsLoad() {
render( render(
@@ -77,4 +108,21 @@ class ArtistDetailScreenTest {
composeRule.onNodeWithText("Album artist").assertDoesNotExist() composeRule.onNodeWithText("Album artist").assertDoesNotExist()
composeRule.onNodeWithText("0 albums").assertDoesNotExist() composeRule.onNodeWithText("0 albums").assertDoesNotExist()
} }
private fun album(
id: String,
name: String,
) = Album(
sourceId = 1,
id = id,
artistId = "ar-1",
name = name,
albumArtist = "Radiohead",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 1,
)
} }