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 package com.subtracks.ui.library
import androidx.compose.foundation.clickable 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
@@ -64,6 +67,7 @@ fun AlbumDetailScreen(
playingSongId: String? = null, playingSongId: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val multiDisc = songs.map { it.disc ?: 1L }.distinct().size > 1
HeroDetailScaffold( HeroDetailScaffold(
artwork = artwork, artwork = artwork,
title = album?.name.orEmpty(), title = album?.name.orEmpty(),
@@ -88,14 +92,32 @@ fun AlbumDetailScreen(
) )
}, },
content = { rowModifier -> content = { rowModifier ->
itemsIndexed(songs, key = { _, song -> song.id }) { index, song -> 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( SongRow(
song = song, song = song,
isPlaying = song.id == playingSongId, isPlaying = song.id == playingSongId,
modifier = rowModifier.clickable { onSongClick(index) }, modifier = rowModifier.clickable { onSongClick(index) },
) )
} }
}
}, },
modifier = modifier, 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.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot 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.PagingData
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
@@ -155,6 +157,36 @@ class AppScreenshotTest {
composeRule.onRoot().captureRoboImage("src/test/screenshots/album_detail_cool.png") 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 @Test
fun artistDetail() { fun artistDetail() {
composeRule.setContent { composeRule.setContent {
@@ -489,7 +521,7 @@ private object Fixtures {
recentRank = null, recentRank = null,
) )
private fun song( fun song(
id: String, id: String,
albumId: String, albumId: String,
artistId: String, artistId: String,
@@ -498,6 +530,7 @@ private object Fixtures {
artist: String, artist: String,
track: Long, track: Long,
duration: Long, duration: Long,
disc: Long? = 1,
) = Song( ) = Song(
sourceId = 1, sourceId = 1,
id = id, id = id,
@@ -508,7 +541,7 @@ private object Fixtures {
artist = artist, artist = artist,
duration = duration, duration = duration,
track = track, track = track,
disc = 1, disc = disc,
starred = null, starred = null,
genre = null, genre = null,
) )