Polish downloads, settings, and detail titles

Use right/down chevrons in the download manager and order downloaded
tracks by disc and track number, matching the album screen. Move sync
concurrency to the end of the network settings, let the artist hero name
wrap to six lines, and leave the album hero title unbounded.
This commit is contained in:
austinried
2026-10-18 07:16:40 +00:00
parent a41f77d907
commit 7161121922
9 changed files with 121 additions and 28 deletions
@@ -174,7 +174,7 @@ interface DownloadDao {
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " + "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 albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
"LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " + "LEFT JOIN artists ar ON ar.sourceId = al.sourceId AND ar.id = al.artistId " +
"WHERE d.sourceId = :sourceId", "WHERE d.sourceId = :sourceId ORDER BY s.disc, s.track, s.id",
) )
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>> fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
@@ -257,6 +257,7 @@ fun HeroHeader(
thumbnailRef: CoverArtRef? = null, thumbnailRef: CoverArtRef? = null,
comment: String? = null, comment: String? = null,
onSubtitleClick: (() -> Unit)? = null, onSubtitleClick: (() -> Unit)? = null,
nameMaxLines: Int = 2,
) { ) {
Column( Column(
modifier = modifier =
@@ -278,8 +279,8 @@ fun HeroHeader(
text = name, text = name,
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 2, maxLines = nameMaxLines,
overflow = TextOverflow.Ellipsis, overflow = if (nameMaxLines == Int.MAX_VALUE) TextOverflow.Clip else TextOverflow.Ellipsis,
) )
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank() val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
if (subtitleIsTarget) { if (subtitleIsTarget) {
@@ -13,10 +13,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -274,7 +274,7 @@ private fun NodeRow(
leadingContent = { leadingContent = {
IconButton(onClick = onToggle) { IconButton(onClick = onToggle) {
Icon( Icon(
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore, imageVector = if (expanded) Icons.Rounded.KeyboardArrowDown else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand), contentDescription = if (expanded) stringResource(R.string.collapse) else stringResource(R.string.expand),
) )
} }
@@ -117,7 +117,7 @@ internal fun buildTree(
name = albumKey.second.ifBlank { unknownAlbum }, name = albumKey.second.ifBlank { unknownAlbum },
bytes = albumSongs.sumOf { it.size }, bytes = albumSongs.sumOf { it.size },
sortName = albumSongs.first().albumSort.ifBlank { albumKey.second }, sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
songs = albumSongs.sortedBy { it.songSort.lowercase() }, songs = albumSongs,
) )
}.sortedBy { it.sortName.lowercase() }, }.sortedBy { it.sortName.lowercase() },
) )
@@ -235,6 +235,7 @@ fun AlbumDetailScreen(
onMore = onMore, onMore = onMore,
topInset = topInset, topInset = topInset,
controlsModifier = controlsModifier, controlsModifier = controlsModifier,
nameMaxLines = Int.MAX_VALUE,
) )
}, },
content = { rowModifier -> content = { rowModifier ->
@@ -52,6 +52,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -136,6 +137,7 @@ internal fun estimateScrollPx(
} }
private val FADE_LEAD = 24.dp private val FADE_LEAD = 24.dp
private const val ARTIST_NAME_MAX_LINES = 6
private const val THEME_TRANSITION_MS = 100 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"
@@ -255,7 +257,8 @@ fun ArtistDetailScreen(
val albumCount = albums.size.toLong() val albumCount = albums.size.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 (isAlbumArtist == true) R.string.artist_type_album_artist else R.string.artist_type_artist)
val imageTitleTop = ART_HEIGHT - TITLE_INSET - lineHeight var titleLineCount by remember { mutableIntStateOf(1) }
val imageTitleTop = ART_HEIGHT - TITLE_INSET - lineHeight * titleLineCount
val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() } val fadeStartPx = with(density) { (imageTitleTop - barHeight - FADE_LEAD).toPx() }
val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() } val fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
val barFraction by remember(fadeStartPx, fadeEndPx) { val barFraction by remember(fadeStartPx, fadeEndPx) {
@@ -351,6 +354,7 @@ fun ArtistDetailScreen(
color = Color.White, color = Color.White,
endReserve = SHUFFLE_RESERVE, endReserve = SHUFFLE_RESERVE,
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(TITLE_INSET), modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(TITLE_INSET),
onLineCountChanged = { titleLineCount = it },
) )
FilledIconButton( FilledIconButton(
onClick = onShuffle, onClick = onShuffle,
@@ -518,13 +522,19 @@ private fun ArtistTitle(
color: Color, color: Color,
endReserve: Dp, endReserve: Dp,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
maxLines: Int = ARTIST_NAME_MAX_LINES,
onLineCountChanged: (Int) -> Unit = {},
) { ) {
val measurer = rememberTextMeasurer() val measurer = rememberTextMeasurer()
val density = LocalDensity.current val density = LocalDensity.current
BoxWithConstraints(modifier) { BoxWithConstraints(modifier) {
val fullWidth = with(density) { maxWidth.roundToPx() } val fullWidth = with(density) { maxWidth.roundToPx() }
val reserve = with(density) { endReserve.roundToPx() } val reserve = with(density) { endReserve.roundToPx() }
val lines = remember(text, style, fullWidth, reserve) { splitTitle(measurer, text, style, fullWidth, reserve) } val lines =
remember(text, style, fullWidth, reserve, maxLines) {
splitTitle(measurer, text, style, fullWidth, reserve, maxLines)
}
LaunchedEffect(lines.size) { onLineCountChanged(lines.size) }
Column { Column {
lines.forEachIndexed { index, line -> lines.forEachIndexed { index, line ->
Text( Text(
@@ -549,6 +559,7 @@ internal fun splitTitle(
style: TextStyle, style: TextStyle,
fullWidth: Int, fullWidth: Int,
reserve: Int, reserve: Int,
maxLines: Int = ARTIST_NAME_MAX_LINES,
): List<String> { ): List<String> {
if (text.isBlank()) return listOf(text) if (text.isBlank()) return listOf(text)
if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) { if (measurer.measure(AnnotatedString(text), style, maxLines = 1).size.width <= fullWidth - reserve) {
@@ -556,18 +567,36 @@ internal fun splitTitle(
} }
val words = text.split(' ').filter { it.isNotEmpty() } val words = text.split(' ').filter { it.isNotEmpty() }
if (words.size <= 1) return listOf(text) if (words.size <= 1) return listOf(text)
var count = 0 val lines = mutableListOf<String>()
var acc = "" var index = 0
for (i in words.indices) { while (index < words.size && lines.size < maxLines) {
val candidate = if (acc.isEmpty()) words[i] else "$acc ${words[i]}" val remaining = words.subList(index, words.size).joinToString(" ")
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) { if (lines.size == maxLines - 1 ||
acc = candidate measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= fullWidth - reserve
count = i + 1 ) {
} else { lines.add(remaining)
break break
} }
var end = index
var acc = ""
while (end < words.size) {
val candidate = if (acc.isEmpty()) words[end] else "$acc ${words[end]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
acc = candidate
end++
} else {
break
}
}
if (end == index) {
acc = words[index]
end = index + 1
} else if (end >= words.size && end - index > 1) {
end -= 1
acc = words.subList(index, end).joinToString(" ")
}
lines.add(acc)
index = end
} }
if (count >= words.size) count = words.size - 1 return lines
if (count < 1) count = 1
return listOf(words.take(count).joinToString(" "), words.drop(count).joinToString(" "))
} }
@@ -324,14 +324,6 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
item {
ListItem(
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { item {
ListItem( ListItem(
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) }, headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
@@ -348,6 +340,14 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
item {
ListItem(
headlineContent = { Text(stringResource(R.string.sync_concurrency)) },
supportingContent = { Text(concurrencyLabel(LocalResources.current, syncConcurrency)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader(stringResource(R.string.settings_music_name)) } item { SectionHeader(stringResource(R.string.settings_music_name)) }
item { item {
ListItem( ListItem(
@@ -975,6 +975,53 @@ class DownloadRepositoryTest {
assertFalse(file(1, "s2").exists()) assertFalse(file(1, "s2").exists())
} }
@Test
fun downloadedSongsFollowTheAlbumDiscAndTrackOrder() =
runTest {
seedLibrary()
runBlocking {
db.libraryDao().upsertSongs(
listOf(
Song(
sourceId = 1,
id = "s0",
albumId = "al1",
artistId = "ar1",
title = "Alpha",
album = "Album",
artist = "Artist",
duration = 100,
track = 3,
disc = 1,
starred = null,
genre = null,
),
Song(
sourceId = 1,
id = "s3",
albumId = "al1",
artistId = "ar1",
title = "Zulu",
album = "Album",
artist = "Artist",
duration = 100,
track = 1,
disc = 2,
starred = null,
genre = null,
),
),
)
listOf("s0", "s1", "s2", "s3").forEach {
db.downloadDao().upsert(SongDownload(1, it, DownloadStatus.Completed))
}
}
val songs = runBlocking { repository.downloadedSongs(1).first() }
assertEquals(listOf("s1", "s2", "s0", "s3"), songs.map { it.songId })
}
@Test @Test
fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() = fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() =
runTest { runTest {
@@ -49,6 +49,21 @@ class HangingTitleTest {
assertEquals(listOf("Boards"), splitTitle(measurer!!, "Boards", style, fullWidth, reserve)) assertEquals(listOf("Boards"), splitTitle(measurer!!, "Boards", style, fullWidth, reserve))
} }
@Test
fun aLongNameWrapsToAtMostSixLines() {
setUpMeasurements()
val text = (1..200).joinToString(" ") { "word$it" }
val lines = splitTitle(measurer!!, text, style, fullWidth, reserve)
assertEquals(6, lines.size)
lines.dropLast(1).forEach { line ->
val width = measurer!!.measure(AnnotatedString(line), style, maxLines = 1).size.width
assertTrue("every line before the last must fit the full width", width <= fullWidth)
}
assertTrue(lines.last().isNotEmpty())
}
@Test @Test
fun theLastLineStopsBeforeTheReservedWidth() { fun theLastLineStopsBeforeTheReservedWidth() {
setUpMeasurements() setUpMeasurements()