home: add an On repeat song row and move recently starred below genres

This commit is contained in:
austinried
2026-10-04 00:30:06 +00:00
parent b0a31d0291
commit e36a0e3de5
9 changed files with 114 additions and 15 deletions
@@ -304,6 +304,18 @@ interface LibraryDao {
limit: Int, limit: Int,
): Flow<List<AlbumSongItem>> ): Flow<List<AlbumSongItem>>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.played IS NOT NULL AND songs.played >= :cutoff " +
"ORDER BY songs.playCount DESC, songs.title COLLATE NOCASE, songs.id LIMIT :limit",
)
fun onRepeatSongs(
sourceId: Long,
cutoff: Long,
limit: Int,
): Flow<List<AlbumSongItem>>
@Query( @Query(
"SELECT genre FROM (" + "SELECT genre FROM (" +
"SELECT song_genres.genre AS genre, songs.playCount AS plays FROM song_genres " + "SELECT song_genres.genre AS genre, songs.playCount AS plays FROM song_genres " +
@@ -710,6 +722,26 @@ interface LibraryDao {
) )
suspend fun starredSongIds(sourceId: Long): List<String> suspend fun starredSongIds(sourceId: Long): List<String>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.played IS NOT NULL AND songs.played >= :cutoff " +
"ORDER BY songs.playCount DESC, songs.title COLLATE NOCASE, songs.id",
)
fun homeOnRepeatSongs(
sourceId: Long,
cutoff: Long,
): PagingSource<Int, AlbumSongItem>
@Query(
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND played IS NOT NULL AND played >= :cutoff " +
"ORDER BY playCount DESC, title COLLATE NOCASE, id",
)
suspend fun onRepeatSongIds(
sourceId: Long,
cutoff: Long,
): List<String>
@Query( @Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
@@ -90,6 +90,16 @@ class LibraryRepository(
suspend fun starredSongIds(sourceId: Long): List<String> = db.libraryDao().starredSongIds(sourceId) suspend fun starredSongIds(sourceId: Long): List<String> = db.libraryDao().starredSongIds(sourceId)
fun onRepeatSongs(
sourceId: Long,
limit: Int,
): Flow<List<AlbumSongItem>> = db.libraryDao().onRepeatSongs(sourceId, weekAgoCutoff(), limit)
fun onRepeatSongsPage(sourceId: Long): Flow<PagingData<AlbumSongItem>> =
pager(60) { db.libraryDao().homeOnRepeatSongs(sourceId, weekAgoCutoff()) }
suspend fun onRepeatSongIds(sourceId: Long): List<String> = db.libraryDao().onRepeatSongIds(sourceId, weekAgoCutoff())
fun songsByGenre( fun songsByGenre(
sourceId: Long, sourceId: Long,
genre: String, genre: String,
@@ -438,3 +448,5 @@ class LibraryRepository(
const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000 const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000
fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS
fun weekAgoCutoff(): Long = System.currentTimeMillis() / 1000 - 7L * 24 * 60 * 60
@@ -169,7 +169,7 @@ fun HomeListScreen(
) )
} }
HomeSection.RecentlyStarredSongs -> { HomeSection.RecentlyStarredSongs, HomeSection.OnRepeatSongs -> {
SongsList( SongsList(
songs = songs, songs = songs,
coverArt = coverArt, coverArt = coverArt,
@@ -91,6 +91,7 @@ class HomeListViewModel(
when { when {
request.genre != null -> libraryRepository.songsByGenre(id, request.genre) request.genre != null -> libraryRepository.songsByGenre(id, request.genre)
request.section == HomeSection.RecentlyStarredSongs -> libraryRepository.starredSongs(id) request.section == HomeSection.RecentlyStarredSongs -> libraryRepository.starredSongs(id)
request.section == HomeSection.OnRepeatSongs -> libraryRepository.onRepeatSongsPage(id)
else -> flowOf(PagingData.empty<AlbumSongItem>()) else -> flowOf(PagingData.empty<AlbumSongItem>())
} }
}.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
@@ -111,10 +112,10 @@ class HomeListViewModel(
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
fun play(song: Song) { fun play(song: Song) {
if (request.section == HomeSection.RecentlyStarredSongs) { when (request.section) {
viewModelScope.playStarredList(libraryRepository, playbackController, song) HomeSection.RecentlyStarredSongs -> viewModelScope.playStarredList(libraryRepository, playbackController, song)
} else { HomeSection.OnRepeatSongs -> viewModelScope.playOnRepeatList(libraryRepository, playbackController, song)
viewModelScope.playSongInContext(libraryRepository, playbackController, song) else -> viewModelScope.playSongInContext(libraryRepository, playbackController, song)
} }
} }
} }
@@ -65,6 +65,7 @@ enum class HomeSection(
Genres("Genres"), Genres("Genres"),
Decades("Decades"), Decades("Decades"),
RecentlyStarredSongs("Recently starred"), RecentlyStarredSongs("Recently starred"),
OnRepeatSongs("On repeat"),
RecentlyAddedAlbums("Recently added"), RecentlyAddedAlbums("Recently added"),
Rediscover("Rediscover"), Rediscover("Rediscover"),
; ;
@@ -109,6 +110,7 @@ fun HomeRoute(
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onPlayStarred = viewModel::playStarred, onPlayStarred = viewModel::playStarred,
onPlayOnRepeat = viewModel::playOnRepeat,
onLongClick = { contextMenuHost?.show(it, actions) }, onLongClick = { contextMenuHost?.show(it, actions) },
onMore = onMore, onMore = onMore,
onGenreClick = onGenreClick, onGenreClick = onGenreClick,
@@ -127,6 +129,7 @@ fun HomeScreen(
onAlbumClick: (Album) -> Unit = {}, onAlbumClick: (Album) -> Unit = {},
onArtistClick: (Artist) -> Unit = {}, onArtistClick: (Artist) -> Unit = {},
onPlayStarred: (Song) -> Unit = {}, onPlayStarred: (Song) -> Unit = {},
onPlayOnRepeat: (Song) -> Unit = {},
onLongClick: (MenuTarget) -> Unit = {}, onLongClick: (MenuTarget) -> Unit = {},
onMore: (HomeSection) -> Unit = {}, onMore: (HomeSection) -> Unit = {},
onGenreClick: (String) -> Unit = {}, onGenreClick: (String) -> Unit = {},
@@ -186,12 +189,12 @@ fun HomeScreen(
onLongClick, onLongClick,
onMore, onMore,
) )
starredSongs( homeSongs(
HomeSection.RecentlyStarredSongs, HomeSection.OnRepeatSongs,
feed.recentlyStarredSongs, feed.onRepeatSongs,
coverArt, coverArt,
playingSongId, playingSongId,
onPlayStarred, onPlayOnRepeat,
onLongClick, onLongClick,
onMore, onMore,
) )
@@ -204,6 +207,15 @@ fun HomeScreen(
onMore, onMore,
) )
genreBlock(HomeSection.Genres, feed.genres, onGenreClick, onMore) genreBlock(HomeSection.Genres, feed.genres, onGenreClick, onMore)
homeSongs(
HomeSection.RecentlyStarredSongs,
feed.recentlyStarredSongs,
coverArt,
playingSongId,
onPlayStarred,
onLongClick,
onMore,
)
artistRow( artistRow(
HomeSection.MostPlayedArtists, HomeSection.MostPlayedArtists,
feed.mostPlayedArtists, feed.mostPlayedArtists,
@@ -250,7 +262,7 @@ private fun HomeSectionHeader(
onMore: (() -> Unit)?, onMore: (() -> Unit)?,
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 28.dp, bottom = 0.dp), modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 24.dp, bottom = 0.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
@@ -391,12 +403,12 @@ private fun LazyListScope.decadeRow(
} }
} }
private fun LazyListScope.starredSongs( private fun LazyListScope.homeSongs(
section: HomeSection, section: HomeSection,
songs: List<AlbumSongItem>, songs: List<AlbumSongItem>,
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?, playingSongId: String?,
onPlayStarred: (Song) -> Unit, onSongPlay: (Song) -> Unit,
onLongClick: (MenuTarget) -> Unit, onLongClick: (MenuTarget) -> Unit,
onMore: (HomeSection) -> Unit, onMore: (HomeSection) -> Unit,
) { ) {
@@ -413,7 +425,7 @@ private fun LazyListScope.starredSongs(
durationSeconds = item.song.duration, durationSeconds = item.song.duration,
modifier = modifier =
Modifier.combinedClickable( Modifier.combinedClickable(
onClick = { onPlayStarred(item.song) }, onClick = { onSongPlay(item.song) },
onLongClick = { onLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) }, onLongClick = { onLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) },
), ),
) )
@@ -35,6 +35,7 @@ data class HomeFeed(
val genres: List<String> = emptyList(), val genres: List<String> = emptyList(),
val decades: List<Long> = emptyList(), val decades: List<Long> = emptyList(),
val recentlyStarredSongs: List<AlbumSongItem> = emptyList(), val recentlyStarredSongs: List<AlbumSongItem> = emptyList(),
val onRepeatSongs: List<AlbumSongItem> = emptyList(),
val recentlyAddedAlbums: List<Album> = emptyList(), val recentlyAddedAlbums: List<Album> = emptyList(),
val rediscoverAlbums: List<Album> = emptyList(), val rediscoverAlbums: List<Album> = emptyList(),
val loading: Boolean = false, val loading: Boolean = false,
@@ -62,8 +63,9 @@ class HomeViewModel(
libraryRepository.recentlyPlayedArtists(sourceId, HOME_ROW_LIMIT), libraryRepository.recentlyPlayedArtists(sourceId, HOME_ROW_LIMIT),
libraryRepository.mostPlayedAlbums(sourceId, HOME_ROW_LIMIT), libraryRepository.mostPlayedAlbums(sourceId, HOME_ROW_LIMIT),
libraryRepository.mostPlayedArtists(sourceId, HOME_ROW_LIMIT), libraryRepository.mostPlayedArtists(sourceId, HOME_ROW_LIMIT),
) { albums, artists, frequentAlbums, frequentArtists -> libraryRepository.onRepeatSongs(sourceId, HOME_STARRED_LIMIT),
PlayRows(albums, artists, frequentAlbums, frequentArtists) ) { albums, artists, frequentAlbums, frequentArtists, onRepeat ->
PlayRows(albums, artists, frequentAlbums, frequentArtists, onRepeat)
} }
val discovery = val discovery =
combine( combine(
@@ -86,6 +88,7 @@ class HomeViewModel(
recentArtists = rows.recentArtists, recentArtists = rows.recentArtists,
frequentAlbums = rows.frequentAlbums, frequentAlbums = rows.frequentAlbums,
frequentArtists = rows.frequentArtists, frequentArtists = rows.frequentArtists,
onRepeatSongs = rows.onRepeatSongs,
genres = found.genres, genres = found.genres,
decades = found.decades, decades = found.decades,
starredSongs = found.starredSongs, starredSongs = found.starredSongs,
@@ -105,6 +108,10 @@ class HomeViewModel(
viewModelScope.playStarredList(libraryRepository, playbackController, song) viewModelScope.playStarredList(libraryRepository, playbackController, song)
} }
fun playOnRepeat(song: Song) {
viewModelScope.playOnRepeatList(libraryRepository, playbackController, song)
}
fun sync() = syncManager.requestSync() fun sync() = syncManager.requestSync()
private data class PlayRows( private data class PlayRows(
@@ -112,6 +119,7 @@ class HomeViewModel(
val recentArtists: List<Artist>, val recentArtists: List<Artist>,
val frequentAlbums: List<Album>, val frequentAlbums: List<Album>,
val frequentArtists: List<Artist>, val frequentArtists: List<Artist>,
val onRepeatSongs: List<AlbumSongItem>,
) )
private data class Discovery( private data class Discovery(
@@ -134,6 +142,7 @@ internal fun buildHomeFeed(
addedAlbums: List<Album>, addedAlbums: List<Album>,
rediscover: List<Album>, rediscover: List<Album>,
hasPlayData: Boolean, hasPlayData: Boolean,
onRepeatSongs: List<AlbumSongItem> = emptyList(),
): HomeFeed = ): HomeFeed =
HomeFeed( HomeFeed(
recentlyPlayedAlbums = recentAlbums.takeIf { hasPlayData }.orEmpty(), recentlyPlayedAlbums = recentAlbums.takeIf { hasPlayData }.orEmpty(),
@@ -143,6 +152,7 @@ internal fun buildHomeFeed(
genres = genres, genres = genres,
decades = decades, decades = decades,
recentlyStarredSongs = starredSongs, recentlyStarredSongs = starredSongs,
onRepeatSongs = onRepeatSongs,
recentlyAddedAlbums = addedAlbums, recentlyAddedAlbums = addedAlbums,
rediscoverAlbums = rediscover, rediscoverAlbums = rediscover,
) )
@@ -173,3 +183,14 @@ internal fun CoroutineScope.playStarredList(
playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0)) playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0))
} }
} }
internal fun CoroutineScope.playOnRepeatList(
libraryRepository: LibraryRepository,
playbackController: PlaybackController,
song: Song,
) {
launch {
val ids = libraryRepository.onRepeatSongIds(song.sourceId)
playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0))
}
}
@@ -335,6 +335,24 @@ class LibraryHomeDaoTest {
assertEquals(displayed, dao.starredSongIds(sourceId)) assertEquals(displayed, dao.starredSongIds(sourceId))
} }
@Test
fun onRepeatKeepsSongsPlayedThisWeekAndOrdersByPlayCount() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", playCount = 3, played = 900),
song(sourceId, "s2", "Two", playCount = 9, played = 950),
song(sourceId, "s3", "Three", playCount = 100, played = 100),
),
)
val onRepeat = dao.onRepeatSongs(sourceId, cutoff = 500, limit = 5).first()
assertEquals(listOf("s2", "s1"), onRepeat.map { it.song.id })
assertEquals(listOf("s2", "s1"), dao.onRepeatSongIds(sourceId, cutoff = 500))
}
@Test @Test
fun songsByGenrePagingFiltersAndJoinsAlbumCoverArt() = fun songsByGenrePagingFiltersAndJoinsAlbumCoverArt() =
runTest { runTest {
@@ -131,6 +131,7 @@ class HomeScreenshotTest {
genres = listOf("Rock", "Jazz", "Classical", "Electronic", "Pop", "Folk", "Blues"), genres = listOf("Rock", "Jazz", "Classical", "Electronic", "Pop", "Folk", "Blues"),
decades = listOf(1960L, 1970L, 1980L, 1990L, 2000L, 2010L), decades = listOf(1960L, 1970L, 1980L, 1990L, 2000L, 2010L),
recentlyStarredSongs = songs(), recentlyStarredSongs = songs(),
onRepeatSongs = songs(),
recentlyAddedAlbums = albums("Added"), recentlyAddedAlbums = albums("Added"),
rediscoverAlbums = albums("Repeat"), rediscoverAlbums = albums("Repeat"),
) )
@@ -26,6 +26,7 @@ class HomeViewModelTest {
addedAlbums = listOf(album("c")), addedAlbums = listOf(album("c")),
rediscover = listOf(album("d")), rediscover = listOf(album("d")),
hasPlayData = true, hasPlayData = true,
onRepeatSongs = listOf(starred("repeat")),
) )
assertEquals(listOf("a"), feed.recentlyPlayedAlbums.map { it.id }) assertEquals(listOf("a"), feed.recentlyPlayedAlbums.map { it.id })
@@ -35,6 +36,7 @@ class HomeViewModelTest {
assertEquals(listOf("Rock"), feed.genres) assertEquals(listOf("Rock"), feed.genres)
assertEquals(listOf(1970L), feed.decades) assertEquals(listOf(1970L), feed.decades)
assertEquals(listOf("song"), feed.recentlyStarredSongs.map { it.song.id }) assertEquals(listOf("song"), feed.recentlyStarredSongs.map { it.song.id })
assertEquals(listOf("repeat"), feed.onRepeatSongs.map { it.song.id })
assertEquals(listOf("c"), feed.recentlyAddedAlbums.map { it.id }) assertEquals(listOf("c"), feed.recentlyAddedAlbums.map { it.id })
assertEquals(listOf("d"), feed.rediscoverAlbums.map { it.id }) assertEquals(listOf("d"), feed.rediscoverAlbums.map { it.id })
} }