home: paginate the offline lists and add More to each downloaded section

This commit is contained in:
austinried
2026-10-04 00:30:06 +00:00
parent c44987ff31
commit 3df16826e6
9 changed files with 238 additions and 66 deletions
@@ -727,6 +727,15 @@ interface LibraryDao {
)
suspend fun downloadedSongIds(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 " +
"JOIN song_downloads d ON d.sourceId = songs.sourceId AND d.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
)
fun homeDownloadedSongs(sourceId: Long): PagingSource<Int, AlbumSongItem>
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
@@ -94,6 +94,8 @@ class LibraryRepository(
suspend fun downloadedSongIds(sourceId: Long): List<String> = db.libraryDao().downloadedSongIds(sourceId)
fun downloadedSongsPage(sourceId: Long): Flow<PagingData<AlbumSongItem>> = pager(60) { db.libraryDao().homeDownloadedSongs(sourceId) }
fun songsByGenre(
sourceId: Long,
genre: String,
@@ -72,6 +72,7 @@ import com.subtracks.ui.downloads.DownloadsRoute
import com.subtracks.ui.home.HomeListRequest
import com.subtracks.ui.home.HomeListRoute
import com.subtracks.ui.home.HomeSection
import com.subtracks.ui.home.OfflineListKind
import com.subtracks.ui.library.AlbumDetailRoute
import com.subtracks.ui.library.ArtistDetailRoute
import com.subtracks.ui.library.LibraryRoute
@@ -123,6 +124,7 @@ private object Routes {
const val HOME_SECTION = "home/more/{section}"
const val HOME_GENRE = "home/genre/{genre}"
const val HOME_DECADE = "home/decade/{decade}"
const val HOME_DOWNLOADED = "home/downloaded/{kind}"
fun album(
id: String,
@@ -143,6 +145,8 @@ private object Routes {
fun homeGenre(genre: String) = "home/genre/${Uri.encode(genre)}"
fun homeDecade(decade: Long) = "home/decade/$decade"
fun homeDownloaded(kind: OfflineListKind) = "home/downloaded/${kind.name}"
}
private val DETAIL_ROUTES =
@@ -364,6 +368,7 @@ private fun MainNavigation() {
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
},
onHomeMore = { section -> navController.navigate(Routes.homeSection(section)) },
onHomeDownloaded = { kind -> navController.navigate(Routes.homeDownloaded(kind)) },
onGenreClick = { genre -> navController.navigate(Routes.homeGenre(genre)) },
onDecadeClick = { decade -> navController.navigate(Routes.homeDecade(decade)) },
contextMenuHost = contextMenuHost,
@@ -526,6 +531,35 @@ private fun MainNavigation() {
setStar = libraryRepository::star,
)
}
composable(
route = Routes.HOME_DOWNLOADED,
arguments = listOf(navArgument("kind") { type = NavType.StringType }),
) { entry ->
val kind =
OfflineListKind.entries.firstOrNull { it.name == entry.arguments?.getString("kind") }
?: return@composable
HomeListRoute(
request = HomeListRequest(title = kind.title, downloaded = kind),
onBack = { navController.popBackStack() },
onAlbumClick = { album ->
navigateDetail(Routes.ALBUM_DETAIL, "albumId", album.id, Routes.album(album.id, album.coverArt))
},
onArtistClick = { artist ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artist.id, Routes.artist(artist.id, artist.coverArt))
},
onPlaylistClick = { playlist ->
navigateDetail(Routes.PLAYLIST_DETAIL, "playlistId", playlist.id, Routes.playlist(playlist.id))
},
onViewAlbum = { albumId -> navigateDetail(Routes.ALBUM_DETAIL, "albumId", albumId, Routes.album(albumId)) },
onViewArtist = { artistId ->
navigateDetail(Routes.ARTIST_DETAIL, "artistId", artistId, Routes.artist(artistId))
},
onGenreClick = { genre -> navController.navigate(Routes.homeGenre(genre)) },
onDecadeClick = { decade -> navController.navigate(Routes.homeDecade(decade)) },
contextMenuHost = contextMenuHost,
setStar = libraryRepository::star,
)
}
composable(Routes.ADD_SERVER) {
AddSourceRoute(
onSaved = { navController.popBackStack() },
@@ -45,6 +45,7 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
@@ -53,6 +54,7 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.library.PlaylistsContent
import com.subtracks.ui.library.SongRow
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
@@ -64,6 +66,7 @@ fun HomeListRoute(
onBack: () -> Unit,
onAlbumClick: (Album) -> Unit,
onArtistClick: (Artist) -> Unit,
onPlaylistClick: (Playlist) -> Unit = {},
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
onGenreClick: (String) -> Unit,
@@ -91,6 +94,7 @@ fun HomeListRoute(
albums = viewModel.albums.collectAsLazyPagingItems(),
artists = viewModel.artists.collectAsLazyPagingItems(),
songs = viewModel.songs.collectAsLazyPagingItems(),
playlists = viewModel.playlists.collectAsLazyPagingItems(),
genres = genres,
decades = decades,
coverArt = viewModel::coverArt,
@@ -98,6 +102,7 @@ fun HomeListRoute(
onBack = onBack,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick,
onSongClick = viewModel::play,
onLongClick = { contextMenuHost?.show(it, actions) },
onGenreClick = onGenreClick,
@@ -112,6 +117,7 @@ fun HomeListScreen(
albums: LazyPagingItems<Album>,
artists: LazyPagingItems<Artist>,
songs: LazyPagingItems<AlbumSongItem>,
playlists: LazyPagingItems<Playlist>,
genres: List<String> = emptyList(),
decades: List<Long> = emptyList(),
coverArt: (String?, Boolean) -> CoverArtRef? = { _, _ -> null },
@@ -119,6 +125,7 @@ fun HomeListScreen(
onBack: () -> Unit = {},
onAlbumClick: (Album) -> Unit = {},
onArtistClick: (Artist) -> Unit = {},
onPlaylistClick: (Playlist) -> Unit = {},
onSongClick: (Song) -> Unit = {},
onLongClick: (MenuTarget) -> Unit = {},
onGenreClick: (String) -> Unit = {},
@@ -139,63 +146,10 @@ fun HomeListScreen(
)
},
) { padding ->
when (request.section) {
HomeSection.Genres -> {
PlainList(
items = genres,
label = { it },
onClick = onGenreClick,
modifier = Modifier.padding(padding),
)
}
HomeSection.Decades -> {
PlainList(
items = decades,
label = { "${it}s" },
onClick = onDecadeClick,
modifier = Modifier.padding(padding),
)
}
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
ArtistsList(
artists = artists,
coverArt = coverArt,
info = { artistListInfo(request, it, now) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
)
}
HomeSection.RecentlyStarredSongs -> {
SongsList(
songs = songs,
coverArt = coverArt,
playingSongId = playingSongId,
onSongClick = onSongClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
)
}
HomeSection.RecentlyPlayedAlbums,
HomeSection.MostPlayedAlbums,
HomeSection.RecentlyAddedAlbums,
HomeSection.Rediscover,
null,
-> {
if (request.genre != null) {
SongsList(
songs = songs,
coverArt = coverArt,
playingSongId = playingSongId,
onSongClick = onSongClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
)
} else {
val downloaded = request.downloaded
if (downloaded != null) {
when (downloaded) {
OfflineListKind.Albums -> {
AlbumsList(
albums = albums,
coverArt = coverArt,
@@ -205,6 +159,108 @@ fun HomeListScreen(
modifier = Modifier.padding(padding),
)
}
OfflineListKind.Artists -> {
ArtistsList(
artists = artists,
coverArt = coverArt,
info = { artistListInfo(request, it, now) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
)
}
OfflineListKind.Songs -> {
SongsList(
songs = songs,
coverArt = coverArt,
playingSongId = playingSongId,
onSongClick = onSongClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
)
}
OfflineListKind.Playlists -> {
PlaylistsContent(
items = playlists,
coverArt = coverArt,
bottomInset = 0.dp,
onPlaylistClick = onPlaylistClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
)
}
}
} else {
when (request.section) {
HomeSection.Genres -> {
PlainList(
items = genres,
label = { it },
onClick = onGenreClick,
modifier = Modifier.padding(padding),
)
}
HomeSection.Decades -> {
PlainList(
items = decades,
label = { "${it}s" },
onClick = onDecadeClick,
modifier = Modifier.padding(padding),
)
}
HomeSection.RecentlyPlayedArtists, HomeSection.MostPlayedArtists -> {
ArtistsList(
artists = artists,
coverArt = coverArt,
info = { artistListInfo(request, it, now) },
onArtistClick = onArtistClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
)
}
HomeSection.RecentlyStarredSongs -> {
SongsList(
songs = songs,
coverArt = coverArt,
playingSongId = playingSongId,
onSongClick = onSongClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
)
}
HomeSection.RecentlyPlayedAlbums,
HomeSection.MostPlayedAlbums,
HomeSection.RecentlyAddedAlbums,
HomeSection.Rediscover,
null,
-> {
if (request.genre != null) {
SongsList(
songs = songs,
coverArt = coverArt,
playingSongId = playingSongId,
onSongClick = onSongClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
)
} else {
AlbumsList(
albums = albums,
coverArt = coverArt,
info = { albumListInfo(request, it, now) },
onAlbumClick = onAlbumClick,
onLongClick = onLongClick,
modifier = Modifier.padding(padding),
)
}
}
}
}
}
@@ -8,7 +8,12 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController
@@ -26,8 +31,18 @@ data class HomeListRequest(
val section: HomeSection? = null,
val genre: String? = null,
val decade: Long? = null,
val downloaded: OfflineListKind? = null,
)
enum class OfflineListKind(
val title: String,
) {
Albums("Downloaded albums"),
Artists("Downloaded artists"),
Songs("Downloaded songs"),
Playlists("Downloaded playlists"),
}
@OptIn(ExperimentalCoroutinesApi::class)
class HomeListViewModel(
private val libraryRepository: LibraryRepository,
@@ -41,6 +56,17 @@ class HomeListViewModel(
sourceId
.flatMapLatest { id ->
when {
request.downloaded == OfflineListKind.Albums -> {
libraryRepository.albums(
sourceId = id,
sort = AlbumSort.Added,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
}
request.decade != null -> {
libraryRepository.albumsByDecade(id, request.decade)
}
@@ -80,15 +106,43 @@ class HomeListViewModel(
}
else -> {
flowOf(PagingData.empty<Artist>())
if (request.downloaded == OfflineListKind.Artists) {
libraryRepository.artists(
sourceId = id,
sort = ArtistSort.Name,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
} else {
flowOf(PagingData.empty<Artist>())
}
}
}
}.cachedIn(viewModelScope)
val playlists: Flow<PagingData<Playlist>> =
sourceId
.flatMapLatest { id ->
if (request.downloaded == OfflineListKind.Playlists) {
libraryRepository.playlists(
sourceId = id,
sort = PlaylistSort.Name,
descending = false,
search = "",
downloaded = true,
)
} else {
flowOf(PagingData.empty<Playlist>())
}
}.cachedIn(viewModelScope)
val songs: Flow<PagingData<AlbumSongItem>> =
sourceId
.flatMapLatest { id ->
when {
request.downloaded == OfflineListKind.Songs -> libraryRepository.downloadedSongsPage(id)
request.genre != null -> libraryRepository.songsByGenre(id, request.genre)
request.section == HomeSection.RecentlyStarredSongs -> libraryRepository.starredSongs(id)
else -> flowOf(PagingData.empty<AlbumSongItem>())
@@ -117,6 +171,10 @@ class HomeListViewModel(
viewModelScope.playGenreList(libraryRepository, playbackController, genre, song)
}
request.downloaded == OfflineListKind.Songs -> {
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
}
request.section == HomeSection.RecentlyStarredSongs -> {
viewModelScope.playStarredList(
libraryRepository,
@@ -86,6 +86,7 @@ fun HomeRoute(
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
onMore: (HomeSection) -> Unit,
onDownloadedMore: (OfflineListKind) -> Unit,
onGenreClick: (String) -> Unit,
onDecadeClick: (Long) -> Unit,
contextMenuHost: ContextMenuHost? = null,
@@ -121,6 +122,7 @@ fun HomeRoute(
onPlaylistClick = onPlaylistClick,
onSongPlay = viewModel::playDownloaded,
onLongClick = onLongClick,
onMore = onDownloadedMore,
)
} else {
val feed by viewModel.feed.collectAsStateWithLifecycle()
@@ -277,6 +279,7 @@ fun OfflineHomeScreen(
onPlaylistClick: (Playlist) -> Unit = {},
onSongPlay: (Song) -> Unit = {},
onLongClick: (MenuTarget) -> Unit = {},
onMore: (OfflineListKind) -> Unit = {},
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
@@ -285,9 +288,9 @@ fun OfflineHomeScreen(
contentPadding = PaddingValues(top = topInset + 8.dp, bottom = bottomInset + 24.dp),
) {
homeTitle()
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick)
downloadedArtistRow(artists, coverArt, onArtistClick, onLongClick)
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick)
downloadedAlbumRow(albums, coverArt, onAlbumClick, onLongClick) { onMore(OfflineListKind.Albums) }
downloadedArtistRow(artists, coverArt, onArtistClick, onLongClick) { onMore(OfflineListKind.Artists) }
downloadedPlaylistRow(playlists, coverArt, onPlaylistClick, onLongClick) { onMore(OfflineListKind.Playlists) }
songList(
key = "downloaded-songs",
title = "Downloaded songs",
@@ -296,7 +299,7 @@ fun OfflineHomeScreen(
playingSongId = playingSongId,
onSongPlay = onSongPlay,
onLongClick = onLongClick,
onMore = null,
onMore = { onMore(OfflineListKind.Songs) },
)
}
}
@@ -307,10 +310,11 @@ private fun LazyListScope.downloadedAlbumRow(
coverArt: (String?, Boolean) -> CoverArtRef?,
onAlbumClick: (Album) -> Unit,
onLongClick: (MenuTarget) -> Unit,
onMore: () -> Unit,
) {
if (albums.itemCount == 0) return
item(key = "downloaded-albums-header") {
HomeSectionHeader("Downloaded albums", null)
HomeSectionHeader("Downloaded albums") { onMore() }
}
item(key = "downloaded-albums-row") {
LazyRow(
@@ -339,10 +343,11 @@ private fun LazyListScope.downloadedArtistRow(
coverArt: (String?, Boolean) -> CoverArtRef?,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
onMore: () -> Unit,
) {
if (artists.itemCount == 0) return
item(key = "downloaded-artists-header") {
HomeSectionHeader("Downloaded artists", null)
HomeSectionHeader("Downloaded artists") { onMore() }
}
item(key = "downloaded-artists-row") {
LazyRow(
@@ -371,10 +376,11 @@ private fun LazyListScope.downloadedPlaylistRow(
coverArt: (String?, Boolean) -> CoverArtRef?,
onPlaylistClick: (Playlist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
onMore: () -> Unit,
) {
if (playlists.itemCount == 0) return
item(key = "downloaded-playlists-header") {
HomeSectionHeader("Downloaded playlists", null)
HomeSectionHeader("Downloaded playlists") { onMore() }
}
item(key = "downloaded-playlists-row") {
LazyRow(
@@ -104,7 +104,9 @@ class HomeViewModel(
}.cachedIn(viewModelScope)
val downloadedSongs: Flow<List<AlbumSongItem>> =
activeSource.flatMapLatest { libraryRepository.downloadedSongs(it) }
activeSource
.flatMapLatest { libraryRepository.downloadedSongs(it) }
.map { it.take(HOME_STARRED_LIMIT) }
val feed: StateFlow<HomeFeed> =
libraryRepository.activeSourceId
@@ -127,6 +127,7 @@ import com.subtracks.ui.components.bulkRef
import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.home.HomeRoute
import com.subtracks.ui.home.HomeSection
import com.subtracks.ui.home.OfflineListKind
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.librarySurfaceColor
import com.subtracks.ui.theme.playerSurfaceColor
@@ -208,6 +209,7 @@ fun LibraryRoute(
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
onHomeMore: (HomeSection) -> Unit = {},
onHomeDownloaded: (OfflineListKind) -> Unit = {},
onGenreClick: (String) -> Unit = {},
onDecadeClick: (Long) -> Unit = {},
contextMenuHost: ContextMenuHost? = null,
@@ -327,6 +329,7 @@ fun LibraryRoute(
onViewAlbum = onViewAlbum,
onViewArtist = onViewArtist,
onMore = onHomeMore,
onDownloadedMore = onHomeDownloaded,
onGenreClick = onGenreClick,
onDecadeClick = onDecadeClick,
contextMenuHost = contextMenuHost,
@@ -96,6 +96,7 @@ class HomeScreenshotTest {
albums = remember { flowOf(PagingData.from(albums)) }.collectAsLazyPagingItems(),
artists = remember { flowOf(PagingData.empty<Artist>()) }.collectAsLazyPagingItems(),
songs = remember { flowOf(PagingData.empty<AlbumSongItem>()) }.collectAsLazyPagingItems(),
playlists = remember { flowOf(PagingData.empty<Playlist>()) }.collectAsLazyPagingItems(),
coverArt = { _, _ -> null },
)
}
@@ -121,6 +122,7 @@ class HomeScreenshotTest {
albums = remember { flowOf(PagingData.empty<Album>()) }.collectAsLazyPagingItems(),
artists = remember { flowOf(PagingData.from(artists)) }.collectAsLazyPagingItems(),
songs = remember { flowOf(PagingData.empty<AlbumSongItem>()) }.collectAsLazyPagingItems(),
playlists = remember { flowOf(PagingData.empty<Playlist>()) }.collectAsLazyPagingItems(),
coverArt = { _, _ -> null },
)
}