feat(android): Most Played to 3-row dense layout matching web
android / Build + lint + test (push) Failing after 2m44s
android / Build signed release APK (push) Has been skipped

Web Home (web/src/routes/+page.svelte:196) stacks the 75 most-played
tracks into 3 rows of 25 inside a single horizontal scroller; Android
was rendering them as one long LazyRow which makes the section feel
sparse and forces a lot of horizontal scrolling to reach mid-rank
tracks. Operator request 2026-06-01.

Replaces MostPlayedRow's LazyRow with a LazyHorizontalGrid where
rows=Fixed(MOST_PLAYED_ROWS=3). Cards stay 140dp (matching web's
w-36); density gain comes purely from stacking.

Web is row-major (ranks 0..24 across the top row); LazyHorizontalGrid
fills column-major. To match web's visual order, the input list is
pre-chunked into 3 row-major rows then re-flattened column-by-column
before being handed to the grid. With 75 tracks and 3 rows that's
25 columns, ranks 0/25/50 in column 0, 1/26/51 in column 1, etc.

MOST_PLAYED_GRID_HEIGHT_DP=600 budgets ~188dp per row (140 cover +
8 spacer + 2 lines of text), plus 2 * 8dp inter-row spacing.

Diverges from Flutter (still single-row); intentional, parity-map
updated. Backflow to Flutter not tracked yet because the Flutter
client is being retired.
This commit is contained in:
2026-06-01 01:38:10 -04:00
parent 54903c4760
commit 582137790d
@@ -18,6 +18,9 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -813,24 +816,64 @@ private fun MostPlayedRow(
tracks: List<HomeTile<TrackRef>>,
onTap: (List<TrackRef>, Int) -> Unit,
) {
HorizontalScrollRow(title = "Most played") {
items(items = tracks, key = { it.id }) { tile ->
val track = tile.value
if (track == null) {
SkeletonCompactTrackTile()
} else {
CompactTrackTile(
track = track,
onClick = {
val playable = tracks.mapNotNull { it.value }
onTap(playable, playable.indexOfFirst { it.id == track.id })
},
)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Most played",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
// Match web's chunked-rows-in-shared-scroller layout
// (web/src/routes/+page.svelte:196): tracks are dealt into
// MOST_PLAYED_ROWS rows of equal width, all stacked inside one
// horizontal scroller. Web is row-major (ranks 0..24 across the
// top row, 25..49 across the middle, 50..74 across the bottom);
// LazyHorizontalGrid fills column-major, so we re-flatten the
// chunked input column-by-column to match the web order.
val perRow = (tracks.size + MOST_PLAYED_ROWS - 1) / MOST_PLAYED_ROWS
val rows = tracks.chunked(perRow.coerceAtLeast(1))
val cols = rows.maxOfOrNull { it.size } ?: 0
val ordered = buildList {
for (c in 0 until cols) {
for (r in 0 until MOST_PLAYED_ROWS) {
rows.getOrNull(r)?.getOrNull(c)?.let { add(it) }
}
}
}
LazyHorizontalGrid(
rows = GridCells.Fixed(MOST_PLAYED_ROWS),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.height(MOST_PLAYED_GRID_HEIGHT_DP.dp),
) {
gridItems(items = ordered, key = { it.id }) { tile ->
val track = tile.value
if (track == null) {
SkeletonCompactTrackTile()
} else {
CompactTrackTile(
track = track,
onClick = {
val playable = tracks.mapNotNull { it.value }
onTap(playable, playable.indexOfFirst { it.id == track.id })
},
)
}
}
}
}
}
private const val MOST_PLAYED_ROWS = 3
// 3 rows of CompactTrackTile (~188dp intrinsic each: 140dp cover +
// 8dp spacer + 2 lines of text) + 2 * 8dp inter-row spacing = ~580dp;
// 600dp gives breathing room without leaving an obvious gap.
private const val MOST_PLAYED_GRID_HEIGHT_DP = 600
@Composable
private fun SkeletonCompactTrackTile() {
Column(modifier = Modifier.width(140.dp)) {