From e36a0e3de54ae566241075b46f4c5b9daf039d41 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:29:24 +0000 Subject: [PATCH] home: add an On repeat song row and move recently starred below genres --- .../com/subtracks/data/db/LibraryDao.kt | 32 +++++++++++++++++++ .../subtracks/data/repo/LibraryRepository.kt | 12 +++++++ .../com/subtracks/ui/home/HomeListScreen.kt | 2 +- .../subtracks/ui/home/HomeListViewModel.kt | 9 +++--- .../com/subtracks/ui/home/HomeScreen.kt | 28 +++++++++++----- .../com/subtracks/ui/home/HomeViewModel.kt | 25 +++++++++++++-- .../subtracks/data/db/LibraryHomeDaoTest.kt | 18 +++++++++++ .../subtracks/ui/home/HomeScreenshotTest.kt | 1 + .../subtracks/ui/home/HomeViewModelTest.kt | 2 ++ 9 files changed, 114 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt index 54b48899..4944594d 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -304,6 +304,18 @@ interface LibraryDao { limit: Int, ): Flow> + @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> + @Query( "SELECT genre FROM (" + "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 + @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 + + @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 + @Query( "SELECT songs.*, albums.coverArt AS coverArt FROM songs " + "LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " + diff --git a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt index ffb83998..ae06e90a 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -90,6 +90,16 @@ class LibraryRepository( suspend fun starredSongIds(sourceId: Long): List = db.libraryDao().starredSongIds(sourceId) + fun onRepeatSongs( + sourceId: Long, + limit: Int, + ): Flow> = db.libraryDao().onRepeatSongs(sourceId, weekAgoCutoff(), limit) + + fun onRepeatSongsPage(sourceId: Long): Flow> = + pager(60) { db.libraryDao().homeOnRepeatSongs(sourceId, weekAgoCutoff()) } + + suspend fun onRepeatSongIds(sourceId: Long): List = db.libraryDao().onRepeatSongIds(sourceId, weekAgoCutoff()) + fun songsByGenre( sourceId: Long, genre: String, @@ -438,3 +448,5 @@ class LibraryRepository( const val REDISCOVER_WINDOW_MS = 60L * 24 * 60 * 60 * 1000 fun rediscoverCutoff(): Long = System.currentTimeMillis() - REDISCOVER_WINDOW_MS + +fun weekAgoCutoff(): Long = System.currentTimeMillis() / 1000 - 7L * 24 * 60 * 60 diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt index 8360e2c6..c546f20d 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListScreen.kt @@ -169,7 +169,7 @@ fun HomeListScreen( ) } - HomeSection.RecentlyStarredSongs -> { + HomeSection.RecentlyStarredSongs, HomeSection.OnRepeatSongs -> { SongsList( songs = songs, coverArt = coverArt, diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt index 83f902c1..31ce3f52 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeListViewModel.kt @@ -91,6 +91,7 @@ class HomeListViewModel( when { request.genre != null -> libraryRepository.songsByGenre(id, request.genre) request.section == HomeSection.RecentlyStarredSongs -> libraryRepository.starredSongs(id) + request.section == HomeSection.OnRepeatSongs -> libraryRepository.onRepeatSongsPage(id) else -> flowOf(PagingData.empty()) } }.cachedIn(viewModelScope) @@ -111,10 +112,10 @@ class HomeListViewModel( ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) fun play(song: Song) { - if (request.section == HomeSection.RecentlyStarredSongs) { - viewModelScope.playStarredList(libraryRepository, playbackController, song) - } else { - viewModelScope.playSongInContext(libraryRepository, playbackController, song) + when (request.section) { + HomeSection.RecentlyStarredSongs -> viewModelScope.playStarredList(libraryRepository, playbackController, song) + HomeSection.OnRepeatSongs -> viewModelScope.playOnRepeatList(libraryRepository, playbackController, song) + else -> viewModelScope.playSongInContext(libraryRepository, playbackController, song) } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt index a2076758..e7653286 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt @@ -65,6 +65,7 @@ enum class HomeSection( Genres("Genres"), Decades("Decades"), RecentlyStarredSongs("Recently starred"), + OnRepeatSongs("On repeat"), RecentlyAddedAlbums("Recently added"), Rediscover("Rediscover"), ; @@ -109,6 +110,7 @@ fun HomeRoute( onAlbumClick = onAlbumClick, onArtistClick = onArtistClick, onPlayStarred = viewModel::playStarred, + onPlayOnRepeat = viewModel::playOnRepeat, onLongClick = { contextMenuHost?.show(it, actions) }, onMore = onMore, onGenreClick = onGenreClick, @@ -127,6 +129,7 @@ fun HomeScreen( onAlbumClick: (Album) -> Unit = {}, onArtistClick: (Artist) -> Unit = {}, onPlayStarred: (Song) -> Unit = {}, + onPlayOnRepeat: (Song) -> Unit = {}, onLongClick: (MenuTarget) -> Unit = {}, onMore: (HomeSection) -> Unit = {}, onGenreClick: (String) -> Unit = {}, @@ -186,12 +189,12 @@ fun HomeScreen( onLongClick, onMore, ) - starredSongs( - HomeSection.RecentlyStarredSongs, - feed.recentlyStarredSongs, + homeSongs( + HomeSection.OnRepeatSongs, + feed.onRepeatSongs, coverArt, playingSongId, - onPlayStarred, + onPlayOnRepeat, onLongClick, onMore, ) @@ -204,6 +207,15 @@ fun HomeScreen( onMore, ) genreBlock(HomeSection.Genres, feed.genres, onGenreClick, onMore) + homeSongs( + HomeSection.RecentlyStarredSongs, + feed.recentlyStarredSongs, + coverArt, + playingSongId, + onPlayStarred, + onLongClick, + onMore, + ) artistRow( HomeSection.MostPlayedArtists, feed.mostPlayedArtists, @@ -250,7 +262,7 @@ private fun HomeSectionHeader( onMore: (() -> Unit)?, ) { 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, ) { Text( @@ -391,12 +403,12 @@ private fun LazyListScope.decadeRow( } } -private fun LazyListScope.starredSongs( +private fun LazyListScope.homeSongs( section: HomeSection, songs: List, coverArt: (String?, Boolean) -> CoverArtRef?, playingSongId: String?, - onPlayStarred: (Song) -> Unit, + onSongPlay: (Song) -> Unit, onLongClick: (MenuTarget) -> Unit, onMore: (HomeSection) -> Unit, ) { @@ -413,7 +425,7 @@ private fun LazyListScope.starredSongs( durationSeconds = item.song.duration, modifier = Modifier.combinedClickable( - onClick = { onPlayStarred(item.song) }, + onClick = { onSongPlay(item.song) }, onLongClick = { onLongClick(MenuTarget.Song(item.song, coverArt(item.coverArt, true))) }, ), ) diff --git a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt index 3989d8fa..7b313da7 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt @@ -35,6 +35,7 @@ data class HomeFeed( val genres: List = emptyList(), val decades: List = emptyList(), val recentlyStarredSongs: List = emptyList(), + val onRepeatSongs: List = emptyList(), val recentlyAddedAlbums: List = emptyList(), val rediscoverAlbums: List = emptyList(), val loading: Boolean = false, @@ -62,8 +63,9 @@ class HomeViewModel( libraryRepository.recentlyPlayedArtists(sourceId, HOME_ROW_LIMIT), libraryRepository.mostPlayedAlbums(sourceId, HOME_ROW_LIMIT), libraryRepository.mostPlayedArtists(sourceId, HOME_ROW_LIMIT), - ) { albums, artists, frequentAlbums, frequentArtists -> - PlayRows(albums, artists, frequentAlbums, frequentArtists) + libraryRepository.onRepeatSongs(sourceId, HOME_STARRED_LIMIT), + ) { albums, artists, frequentAlbums, frequentArtists, onRepeat -> + PlayRows(albums, artists, frequentAlbums, frequentArtists, onRepeat) } val discovery = combine( @@ -86,6 +88,7 @@ class HomeViewModel( recentArtists = rows.recentArtists, frequentAlbums = rows.frequentAlbums, frequentArtists = rows.frequentArtists, + onRepeatSongs = rows.onRepeatSongs, genres = found.genres, decades = found.decades, starredSongs = found.starredSongs, @@ -105,6 +108,10 @@ class HomeViewModel( viewModelScope.playStarredList(libraryRepository, playbackController, song) } + fun playOnRepeat(song: Song) { + viewModelScope.playOnRepeatList(libraryRepository, playbackController, song) + } + fun sync() = syncManager.requestSync() private data class PlayRows( @@ -112,6 +119,7 @@ class HomeViewModel( val recentArtists: List, val frequentAlbums: List, val frequentArtists: List, + val onRepeatSongs: List, ) private data class Discovery( @@ -134,6 +142,7 @@ internal fun buildHomeFeed( addedAlbums: List, rediscover: List, hasPlayData: Boolean, + onRepeatSongs: List = emptyList(), ): HomeFeed = HomeFeed( recentlyPlayedAlbums = recentAlbums.takeIf { hasPlayData }.orEmpty(), @@ -143,6 +152,7 @@ internal fun buildHomeFeed( genres = genres, decades = decades, recentlyStarredSongs = starredSongs, + onRepeatSongs = onRepeatSongs, recentlyAddedAlbums = addedAlbums, rediscoverAlbums = rediscover, ) @@ -173,3 +183,14 @@ internal fun CoroutineScope.playStarredList( 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)) + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt b/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt index 6681fca6..ce9982fa 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt @@ -335,6 +335,24 @@ class LibraryHomeDaoTest { 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 fun songsByGenrePagingFiltersAndJoinsAlbumCoverArt() = runTest { diff --git a/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt index 1fb952cb..24ec387e 100644 --- a/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt @@ -131,6 +131,7 @@ class HomeScreenshotTest { genres = listOf("Rock", "Jazz", "Classical", "Electronic", "Pop", "Folk", "Blues"), decades = listOf(1960L, 1970L, 1980L, 1990L, 2000L, 2010L), recentlyStarredSongs = songs(), + onRepeatSongs = songs(), recentlyAddedAlbums = albums("Added"), rediscoverAlbums = albums("Repeat"), ) diff --git a/app/src/test/kotlin/com/subtracks/ui/home/HomeViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/home/HomeViewModelTest.kt index d65141ab..c9f2cfc9 100644 --- a/app/src/test/kotlin/com/subtracks/ui/home/HomeViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/home/HomeViewModelTest.kt @@ -26,6 +26,7 @@ class HomeViewModelTest { addedAlbums = listOf(album("c")), rediscover = listOf(album("d")), hasPlayData = true, + onRepeatSongs = listOf(starred("repeat")), ) assertEquals(listOf("a"), feed.recentlyPlayedAlbums.map { it.id }) @@ -35,6 +36,7 @@ class HomeViewModelTest { assertEquals(listOf("Rock"), feed.genres) assertEquals(listOf(1970L), feed.decades) 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("d"), feed.rediscoverAlbums.map { it.id }) }