mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
home polish: genres by most played in a wrapping flow, tighter album corners, spaced left-aligned artists
This commit is contained in:
@@ -306,17 +306,17 @@ interface LibraryDao {
|
|||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT genre FROM (" +
|
"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 " +
|
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||||
"WHERE song_genres.sourceId = :sourceId " +
|
"WHERE song_genres.sourceId = :sourceId " +
|
||||||
"UNION ALL " +
|
"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 != '' " +
|
"WHERE songs.sourceId = :sourceId AND songs.genre IS NOT NULL AND songs.genre != '' " +
|
||||||
"AND NOT EXISTS (SELECT 1 FROM song_genres " +
|
"AND NOT EXISTS (SELECT 1 FROM song_genres " +
|
||||||
"WHERE song_genres.sourceId = songs.sourceId AND song_genres.songId = songs.id)" +
|
"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(
|
@Query(
|
||||||
"SELECT DISTINCT (year / 10) * 10 AS decade FROM albums " +
|
"SELECT DISTINCT (year / 10) * 10 AS decade FROM albums " +
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class LibraryRepository(
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): Flow<List<AlbumSongItem>> = db.libraryDao().recentlyStarredSongs(sourceId, limit)
|
): 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)
|
fun decades(sourceId: Long): Flow<List<Long>> = db.libraryDao().decades(sourceId)
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class HomeListViewModel(
|
|||||||
|
|
||||||
val genres: StateFlow<List<String>> =
|
val genres: StateFlow<List<String>> =
|
||||||
sourceId
|
sourceId
|
||||||
.flatMapLatest { libraryRepository.genresByRecentPlay(it) }
|
.flatMapLatest { libraryRepository.genresByMostPlayed(it) }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
val decades: StateFlow<List<Long>> =
|
val decades: StateFlow<List<Long>> =
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.subtracks.ui.home
|
|||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -246,6 +248,7 @@ private fun HomeSectionHeader(
|
|||||||
private val TILE_SIZE = 132.dp
|
private val TILE_SIZE = 132.dp
|
||||||
private val ARTIST_TILE_SIZE = 104.dp
|
private val ARTIST_TILE_SIZE = 104.dp
|
||||||
private val TILE_SPACING = 8.dp
|
private val TILE_SPACING = 8.dp
|
||||||
|
private val ARTIST_SPACING = 16.dp
|
||||||
|
|
||||||
private fun LazyListScope.albumRow(
|
private fun LazyListScope.albumRow(
|
||||||
section: HomeSection,
|
section: HomeSection,
|
||||||
@@ -292,7 +295,7 @@ private fun LazyListScope.artistRow(
|
|||||||
item(key = "${section.name}-row") {
|
item(key = "${section.name}-row") {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
|
horizontalArrangement = Arrangement.spacedBy(ARTIST_SPACING),
|
||||||
) {
|
) {
|
||||||
items(artists, key = { it.id }) { artist ->
|
items(artists, key = { it.id }) { artist ->
|
||||||
val ref = coverArt(artist.coverArt, true)
|
val ref = coverArt(artist.coverArt, true)
|
||||||
@@ -307,6 +310,7 @@ private fun LazyListScope.artistRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
private fun LazyListScope.genreBlock(
|
private fun LazyListScope.genreBlock(
|
||||||
section: HomeSection,
|
section: HomeSection,
|
||||||
genres: List<String>,
|
genres: List<String>,
|
||||||
@@ -318,16 +322,13 @@ private fun LazyListScope.genreBlock(
|
|||||||
HomeSectionHeader(section.title) { onMore(section) }
|
HomeSectionHeader(section.title) { onMore(section) }
|
||||||
}
|
}
|
||||||
item(key = "${section.name}-row") {
|
item(key = "${section.name}-row") {
|
||||||
LazyRow(
|
FlowRow(
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
|
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(TILE_SPACING),
|
||||||
) {
|
) {
|
||||||
items(genres.chunked(GENRE_COLUMN_HEIGHT), key = { it.first() }) { column ->
|
genres.forEach { genre ->
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(TILE_SPACING)) {
|
AssistChip(onClick = { onGenreClick(genre) }, label = { Text(genre) })
|
||||||
column.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
|
@Composable
|
||||||
private fun AlbumTile(
|
private fun AlbumTile(
|
||||||
album: Album,
|
album: Album,
|
||||||
@@ -399,7 +398,7 @@ private fun AlbumTile(
|
|||||||
CoverArt(
|
CoverArt(
|
||||||
ref = ref,
|
ref = ref,
|
||||||
name = album.name,
|
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))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -427,7 +426,7 @@ private fun ArtistTile(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
modifier = Modifier.width(ARTIST_TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.Start,
|
||||||
) {
|
) {
|
||||||
CoverArt(
|
CoverArt(
|
||||||
ref = ref,
|
ref = ref,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class HomeViewModel(
|
|||||||
}
|
}
|
||||||
val discovery =
|
val discovery =
|
||||||
combine(
|
combine(
|
||||||
libraryRepository.genresByRecentPlay(sourceId),
|
libraryRepository.genresByMostPlayed(sourceId),
|
||||||
libraryRepository.decades(sourceId),
|
libraryRepository.decades(sourceId),
|
||||||
libraryRepository.recentlyStarredSongs(sourceId, HOME_STARRED_LIMIT),
|
libraryRepository.recentlyStarredSongs(sourceId, HOME_STARRED_LIMIT),
|
||||||
libraryRepository.recentlyAddedAlbums(sourceId, HOME_ROW_LIMIT),
|
libraryRepository.recentlyAddedAlbums(sourceId, HOME_ROW_LIMIT),
|
||||||
|
|||||||
@@ -124,22 +124,22 @@ class LibraryHomeDaoTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun genresOrderByMostRecentPlayWithinTheGenre() =
|
fun genresOrderByMostPlayed() =
|
||||||
runTest {
|
runTest {
|
||||||
val sourceId = source()
|
val sourceId = source()
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
dao.upsertSongs(
|
dao.upsertSongs(
|
||||||
listOf(
|
listOf(
|
||||||
song(sourceId, "s1", "One", genre = "Rock", played = 100),
|
song(sourceId, "s1", "One", genre = "Rock", playCount = 5),
|
||||||
song(sourceId, "s2", "Two", genre = "Jazz", played = 400),
|
song(sourceId, "s2", "Two", genre = "Jazz", playCount = 2),
|
||||||
song(sourceId, "s3", "Three", genre = "Rock", played = 300),
|
song(sourceId, "s3", "Three", genre = "Rock", playCount = 4),
|
||||||
song(sourceId, "s4", "Four", genre = "Classical", played = null),
|
song(sourceId, "s4", "Four", genre = "Classical", playCount = 0),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Jazz", "Rock", "Classical"),
|
listOf("Rock", "Jazz", "Classical"),
|
||||||
dao.genresByRecentPlay(sourceId).first(),
|
dao.genresByMostPlayed(sourceId).first(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +150,8 @@ class LibraryHomeDaoTest {
|
|||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
dao.upsertSongs(
|
dao.upsertSongs(
|
||||||
listOf(
|
listOf(
|
||||||
song(sourceId, "s1", "One", genre = "Legacy", played = 100),
|
song(sourceId, "s1", "One", genre = "Legacy", playCount = 1),
|
||||||
song(sourceId, "s2", "Two", genre = "Legacy", played = 300),
|
song(sourceId, "s2", "Two", genre = "Legacy", playCount = 3),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
insertGenre(sourceId, "s2", 0, "Rock")
|
insertGenre(sourceId, "s2", 0, "Rock")
|
||||||
@@ -159,7 +159,7 @@ class LibraryHomeDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Electronic", "Rock", "Legacy"),
|
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.junit4.v2.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithText
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onRoot
|
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||||
import com.github.takahirom.roborazzi.captureRoboImage
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
@@ -48,6 +50,31 @@ class HomeScreenshotTest {
|
|||||||
composeRule.onRoot().captureRoboImage("src/test/screenshots/home.png")
|
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() =
|
private fun feed() =
|
||||||
HomeFeed(
|
HomeFeed(
|
||||||
recentlyPlayedAlbums = albums("Recent"),
|
recentlyPlayedAlbums = albums("Recent"),
|
||||||
|
|||||||
Reference in New Issue
Block a user