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 " +
"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 " +
"WHERE d.sourceId = :sourceId",
"WHERE d.sourceId = :sourceId ORDER BY s.disc, s.track, s.id",
)
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
@@ -257,6 +257,7 @@ fun HeroHeader(
thumbnailRef: CoverArtRef? = null,
comment: String? = null,
onSubtitleClick: (() -> Unit)? = null,
nameMaxLines: Int = 2,
) {
Column(
modifier =
@@ -278,8 +279,8 @@ fun HeroHeader(
text = name,
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
maxLines = nameMaxLines,
overflow = if (nameMaxLines == Int.MAX_VALUE) TextOverflow.Clip else TextOverflow.Ellipsis,
)
val subtitleIsTarget = onSubtitleClick != null && subtitle.isNotEmpty() && comment.isNullOrBlank()
if (subtitleIsTarget) {
@@ -13,10 +13,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
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.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -274,7 +274,7 @@ private fun NodeRow(
leadingContent = {
IconButton(onClick = onToggle) {
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),
)
}
@@ -117,7 +117,7 @@ internal fun buildTree(
name = albumKey.second.ifBlank { unknownAlbum },
bytes = albumSongs.sumOf { it.size },
sortName = albumSongs.first().albumSort.ifBlank { albumKey.second },
songs = albumSongs.sortedBy { it.songSort.lowercase() },
songs = albumSongs,
)
}.sortedBy { it.sortName.lowercase() },
)
@@ -235,6 +235,7 @@ fun AlbumDetailScreen(
onMore = onMore,
topInset = topInset,
controlsModifier = controlsModifier,
nameMaxLines = Int.MAX_VALUE,
)
},
content = { rowModifier ->
@@ -52,6 +52,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -136,6 +137,7 @@ internal fun estimateScrollPx(
}
private val FADE_LEAD = 24.dp
private const val ARTIST_NAME_MAX_LINES = 6
private const val THEME_TRANSITION_MS = 100
private const val SCRIM_FADE_MS = 180
internal const val ARTIST_NAME_SCRIM_TAG = "artistNameScrim"
@@ -255,7 +257,8 @@ fun ArtistDetailScreen(
val albumCount = albums.size.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 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 fadeEndPx = with(density) { (imageTitleTop + lineHeight / 2 - barHeight).toPx() }
val barFraction by remember(fadeStartPx, fadeEndPx) {
@@ -351,6 +354,7 @@ fun ArtistDetailScreen(
color = Color.White,
endReserve = SHUFFLE_RESERVE,
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(TITLE_INSET),
onLineCountChanged = { titleLineCount = it },
)
FilledIconButton(
onClick = onShuffle,
@@ -518,13 +522,19 @@ private fun ArtistTitle(
color: Color,
endReserve: Dp,
modifier: Modifier = Modifier,
maxLines: Int = ARTIST_NAME_MAX_LINES,
onLineCountChanged: (Int) -> Unit = {},
) {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
BoxWithConstraints(modifier) {
val fullWidth = with(density) { maxWidth.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 {
lines.forEachIndexed { index, line ->
Text(
@@ -549,6 +559,7 @@ internal fun splitTitle(
style: TextStyle,
fullWidth: Int,
reserve: Int,
maxLines: Int = ARTIST_NAME_MAX_LINES,
): List<String> {
if (text.isBlank()) return listOf(text)
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() }
if (words.size <= 1) return listOf(text)
var count = 0
var acc = ""
for (i in words.indices) {
val candidate = if (acc.isEmpty()) words[i] else "$acc ${words[i]}"
if (measurer.measure(AnnotatedString(candidate), style, maxLines = 1).size.width <= fullWidth) {
acc = candidate
count = i + 1
} else {
val lines = mutableListOf<String>()
var index = 0
while (index < words.size && lines.size < maxLines) {
val remaining = words.subList(index, words.size).joinToString(" ")
if (lines.size == maxLines - 1 ||
measurer.measure(AnnotatedString(remaining), style, maxLines = 1).size.width <= fullWidth - reserve
) {
lines.add(remaining)
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
if (count < 1) count = 1
return listOf(words.take(count).joinToString(" "), words.drop(count).joinToString(" "))
return lines
}
@@ -324,14 +324,6 @@ fun SettingsScreen(
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 {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
@@ -348,6 +340,14 @@ fun SettingsScreen(
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 {
ListItem(
@@ -975,6 +975,53 @@ class DownloadRepositoryTest {
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
fun theTreeGroupsAnAlbumUnderItsAlbumArtistNotEachTrackArtist() =
runTest {
@@ -49,6 +49,21 @@ class HangingTitleTest {
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
fun theLastLineStopsBeforeTheReservedWidth() {
setUpMeasurements()