home polish: genres by most played in a wrapping flow, tighter album corners, spaced left-aligned artists

This commit is contained in:
austinried
2026-10-04 00:30:06 +00:00
parent 796e5e2827
commit ff236836a2
7 changed files with 56 additions and 30 deletions
@@ -306,17 +306,17 @@ interface LibraryDao {
@Query(
"SELECT genre FROM (" +
"SELECT song_genres.genre AS genre, songs.played AS played FROM song_genres " +
"SELECT song_genres.genre AS genre, songs.playCount AS plays FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId " +
"UNION ALL " +
"SELECT songs.genre AS genre, songs.played AS played FROM songs " +
"SELECT songs.genre AS genre, songs.playCount AS plays FROM songs " +
"WHERE songs.sourceId = :sourceId AND songs.genre IS NOT NULL AND songs.genre != '' " +
"AND NOT EXISTS (SELECT 1 FROM song_genres " +
"WHERE song_genres.sourceId = songs.sourceId AND song_genres.songId = songs.id)" +
") GROUP BY genre ORDER BY MAX(played) DESC NULLS LAST, genre COLLATE NOCASE",
") GROUP BY genre ORDER BY SUM(plays) DESC, genre COLLATE NOCASE",
)
fun genresByRecentPlay(sourceId: Long): Flow<List<String>>
fun genresByMostPlayed(sourceId: Long): Flow<List<String>>
@Query(
"SELECT DISTINCT (year / 10) * 10 AS decade FROM albums " +
@@ -79,7 +79,7 @@ class LibraryRepository(
limit: Int,
): Flow<List<AlbumSongItem>> = db.libraryDao().recentlyStarredSongs(sourceId, limit)
fun genresByRecentPlay(sourceId: Long): Flow<List<String>> = db.libraryDao().genresByRecentPlay(sourceId)
fun genresByMostPlayed(sourceId: Long): Flow<List<String>> = db.libraryDao().genresByMostPlayed(sourceId)
fun decades(sourceId: Long): Flow<List<Long>> = db.libraryDao().decades(sourceId)
@@ -97,7 +97,7 @@ class HomeListViewModel(
val genres: StateFlow<List<String>> =
sourceId
.flatMapLatest { libraryRepository.genresByRecentPlay(it) }
.flatMapLatest { libraryRepository.genresByMostPlayed(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val decades: StateFlow<List<Long>> =
@@ -3,6 +3,8 @@ package com.subtracks.ui.home
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -246,6 +248,7 @@ private fun HomeSectionHeader(
private val TILE_SIZE = 132.dp
private val ARTIST_TILE_SIZE = 104.dp
private val TILE_SPACING = 8.dp
private val ARTIST_SPACING = 16.dp
private fun LazyListScope.albumRow(
section: HomeSection,
@@ -292,7 +295,7 @@ private fun LazyListScope.artistRow(
item(key = "${section.name}-row") {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
horizontalArrangement = Arrangement.spacedBy(ARTIST_SPACING),
) {
items(artists, key = { it.id }) { artist ->
val ref = coverArt(artist.coverArt, true)
@@ -307,6 +310,7 @@ private fun LazyListScope.artistRow(
}
}
@OptIn(ExperimentalLayoutApi::class)
private fun LazyListScope.genreBlock(
section: HomeSection,
genres: List<String>,
@@ -318,16 +322,13 @@ private fun LazyListScope.genreBlock(
HomeSectionHeader(section.title) { onMore(section) }
}
item(key = "${section.name}-row") {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
FlowRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
verticalArrangement = Arrangement.spacedBy(TILE_SPACING),
) {
items(genres.chunked(GENRE_COLUMN_HEIGHT), key = { it.first() }) { column ->
Column(verticalArrangement = Arrangement.spacedBy(TILE_SPACING)) {
column.forEach { genre ->
AssistChip(onClick = { onGenreClick(genre) }, label = { Text(genre) })
}
}
genres.forEach { genre ->
AssistChip(onClick = { onGenreClick(genre) }, label = { Text(genre) })
}
}
}
@@ -384,8 +385,6 @@ private fun LazyListScope.starredSongs(
}
}
private const val GENRE_COLUMN_HEIGHT = 3
@Composable
private fun AlbumTile(
album: Album,
@@ -399,7 +398,7 @@ private fun AlbumTile(
CoverArt(
ref = ref,
name = album.name,
modifier = Modifier.size(TILE_SIZE).clip(RoundedCornerShape(4.dp)),
modifier = Modifier.size(TILE_SIZE).clip(RoundedCornerShape(2.dp)),
)
Spacer(Modifier.height(6.dp))
Text(
@@ -427,7 +426,7 @@ private fun ArtistTile(
) {
Column(
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
horizontalAlignment = Alignment.CenterHorizontally,
horizontalAlignment = Alignment.Start,
) {
CoverArt(
ref = ref,
@@ -67,7 +67,7 @@ class HomeViewModel(
}
val discovery =
combine(
libraryRepository.genresByRecentPlay(sourceId),
libraryRepository.genresByMostPlayed(sourceId),
libraryRepository.decades(sourceId),
libraryRepository.recentlyStarredSongs(sourceId, HOME_STARRED_LIMIT),
libraryRepository.recentlyAddedAlbums(sourceId, HOME_ROW_LIMIT),
@@ -124,22 +124,22 @@ class LibraryHomeDaoTest {
}
@Test
fun genresOrderByMostRecentPlayWithinTheGenre() =
fun genresOrderByMostPlayed() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", genre = "Rock", played = 100),
song(sourceId, "s2", "Two", genre = "Jazz", played = 400),
song(sourceId, "s3", "Three", genre = "Rock", played = 300),
song(sourceId, "s4", "Four", genre = "Classical", played = null),
song(sourceId, "s1", "One", genre = "Rock", playCount = 5),
song(sourceId, "s2", "Two", genre = "Jazz", playCount = 2),
song(sourceId, "s3", "Three", genre = "Rock", playCount = 4),
song(sourceId, "s4", "Four", genre = "Classical", playCount = 0),
),
)
assertEquals(
listOf("Jazz", "Rock", "Classical"),
dao.genresByRecentPlay(sourceId).first(),
listOf("Rock", "Jazz", "Classical"),
dao.genresByMostPlayed(sourceId).first(),
)
}
@@ -150,8 +150,8 @@ class LibraryHomeDaoTest {
val dao = db.libraryDao()
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", genre = "Legacy", played = 100),
song(sourceId, "s2", "Two", genre = "Legacy", played = 300),
song(sourceId, "s1", "One", genre = "Legacy", playCount = 1),
song(sourceId, "s2", "Two", genre = "Legacy", playCount = 3),
),
)
insertGenre(sourceId, "s2", 0, "Rock")
@@ -159,7 +159,7 @@ class LibraryHomeDaoTest {
assertEquals(
listOf("Electronic", "Rock", "Legacy"),
dao.genresByRecentPlay(sourceId).first(),
dao.genresByMostPlayed(sourceId).first(),
)
}
@@ -7,6 +7,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureRoboImage
@@ -48,6 +50,31 @@ class HomeScreenshotTest {
composeRule.onRoot().captureRoboImage("src/test/screenshots/home.png")
}
@Test
fun homeGenres() {
composeRule.setContent {
SubtracksTheme {
Surface(modifier = Modifier.fillMaxSize()) {
HomeScreen(
feed = feed(),
coverArt = { _, _ -> null },
playingSongId = null,
)
}
}
}
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("Recently added").fetchSemanticsNodes().isNotEmpty()
}
repeat(2) {
composeRule.onRoot().performTouchInput {
swipeUp(startY = centerY + 600f, endY = centerY - 600f, durationMillis = 300)
}
composeRule.waitForIdle()
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/home_genres.png")
}
private fun feed() =
HomeFeed(
recentlyPlayedAlbums = albums("Recent"),