show disc headings in multi-disc album track lists

This commit is contained in:
austinried
2026-10-04 00:29:55 +00:00
parent 21df098d2a
commit 9ca6273b82
3 changed files with 165 additions and 9 deletions
@@ -1,11 +1,14 @@
package com.subtracks.ui.library
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef
@@ -64,6 +67,7 @@ fun AlbumDetailScreen(
playingSongId: String? = null,
modifier: Modifier = Modifier,
) {
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
HeroDetailScaffold(
artwork = artwork,
title = album?.name.orEmpty(),
@@ -88,14 +92,32 @@ fun AlbumDetailScreen(
)
},
content = { rowModifier ->
itemsIndexed(songs, key = { _, song -> song.id }) { index, song ->
SongRow(
song = song,
isPlaying = song.id == playingSongId,
modifier = rowModifier.clickable { onSongClick(index) },
)
var lastDisc: Long? = null
songs.forEachIndexed { index, song ->
val disc = song.disc ?: 1L
if (multiDisc && disc != lastDisc) {
item(key = "disc:$disc") { DiscHeader(disc) }
}
lastDisc = disc
item(key = song.id) {
SongRow(
song = song,
isPlaying = song.id == playingSongId,
modifier = rowModifier.clickable { onSongClick(index) },
)
}
}
},
modifier = modifier,
)
}
@Composable
private fun DiscHeader(disc: Long) {
Text(
text = "Disc $disc",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 20.dp, bottom = 4.dp),
)
}
@@ -0,0 +1,101 @@
package com.subtracks.ui
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.subtracks.data.model.Album
import com.subtracks.data.model.Song
import com.subtracks.ui.library.AlbumDetailScreen
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
class AlbumDiscsTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun multiDiscAlbumShowsDiscHeaders() {
setContent(listOf(song("s-a", disc = 1, track = 1), song("s-b", disc = 1, track = 2), song("s-c", disc = 2, track = 1)))
assertEquals(1, nodesWithText("Disc 1"))
assertEquals(1, nodesWithText("Disc 2"))
}
@Test
fun singleDiscAlbumShowsNoDiscHeaders() {
setContent(listOf(song("s-a", disc = 1, track = 1), song("s-b", disc = 1, track = 2)))
assertEquals(0, nodesWithText("Disc 1"))
}
@Test
fun discHeadersAreHiddenWhenDiscsAreMissing() {
setContent(listOf(song("s-a", disc = null, track = 1), song("s-b", disc = null, track = 2)))
assertEquals(0, nodesWithText("Disc 1"))
}
private fun setContent(songs: List<Song>) {
composeRule.setContent {
SubtracksTheme {
AlbumDetailScreen(
album = album(),
songs = songs,
coverArt = { _, _ -> null },
artwork = null,
onBack = {},
onSongClick = {},
modifier = Modifier.fillMaxSize(),
)
}
}
composeRule.waitUntil(timeoutMillis = 5_000) { nodesWithText(songs.first().title) == 1 }
}
private fun nodesWithText(text: String) = composeRule.onAllNodesWithText(text).fetchSemanticsNodes().size
private fun song(
id: String,
disc: Long?,
track: Long,
) = Song(
sourceId = 1,
id = id,
albumId = "al-1",
artistId = "ar-1",
title = "Song $id",
album = "Album",
artist = "Artist",
duration = 200,
track = track,
disc = disc,
starred = null,
genre = null,
)
private fun album() =
Album(
sourceId = 1,
id = "al-1",
artistId = "ar-1",
name = "Album",
albumArtist = "Artist",
created = 0,
coverArt = null,
genre = null,
year = null,
starred = null,
songCount = 3,
frequentRank = null,
recentRank = null,
)
}
@@ -15,6 +15,8 @@ import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.test.core.app.ApplicationProvider
@@ -155,6 +157,36 @@ class AppScreenshotTest {
composeRule.onRoot().captureRoboImage("src/test/screenshots/album_detail_cool.png")
}
@Test
fun albumDetailMultiDisc() {
val songs =
listOf(
Fixtures.song("s-eiirp", "al-kid-a", "ar-radiohead", "Everything In Its Right Place", "Kid A", "Radiohead", 1, 251),
Fixtures.song("s-kid-a", "al-kid-a", "ar-radiohead", "Kid A", "Kid A", "Radiohead", 2, 274),
Fixtures.song("s-anthem", "al-kid-a", "ar-radiohead", "The National Anthem", "Kid A", "Radiohead", 1, 351, disc = 2),
Fixtures.song("s-htdc", "al-kid-a", "ar-radiohead", "How to Disappear Completely", "Kid A", "Radiohead", 2, 356, disc = 2),
)
composeRule.setContent {
SubtracksTheme {
AlbumDetailScreen(
album = Fixtures.albums.first(),
songs = songs,
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
artwork = artworkColorsFromSeed(Color.rgb(120, 80, 200)),
onBack = {},
onSongClick = {},
modifier = Modifier.fillMaxSize(),
)
}
}
awaitText("Everything In Its Right Place")
composeRule.onRoot().performTouchInput { swipeUp(startY = centerY + 600f, endY = centerY - 600f, durationMillis = 400) }
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("Disc 2").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/album_detail_multidisc.png")
}
@Test
fun artistDetail() {
composeRule.setContent {
@@ -489,7 +521,7 @@ private object Fixtures {
recentRank = null,
)
private fun song(
fun song(
id: String,
albumId: String,
artistId: String,
@@ -498,6 +530,7 @@ private object Fixtures {
artist: String,
track: Long,
duration: Long,
disc: Long? = 1,
) = Song(
sourceId = 1,
id = id,
@@ -508,7 +541,7 @@ private object Fixtures {
artist = artist,
duration = duration,
track = track,
disc = 1,
disc = disc,
starred = null,
genre = null,
)