mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user