home: swap to downloaded sections in offline mode

This commit is contained in:
austinried
2026-10-04 00:30:06 +00:00
parent 43c90c3453
commit c44987ff31
7 changed files with 386 additions and 36 deletions
@@ -710,6 +710,23 @@ interface LibraryDao {
)
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 " +
"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 downloadedSongs(sourceId: Long): Flow<List<AlbumSongItem>>
@Query(
"SELECT songs.id FROM songs " +
"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",
)
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 " +
@@ -90,6 +90,10 @@ class LibraryRepository(
suspend fun starredSongIds(sourceId: Long): List<String> = db.libraryDao().starredSongIds(sourceId)
fun downloadedSongs(sourceId: Long): Flow<List<AlbumSongItem>> = db.libraryDao().downloadedSongs(sourceId)
suspend fun downloadedSongIds(sourceId: Long): List<String> = db.libraryDao().downloadedSongIds(sourceId)
fun songsByGenre(
sourceId: Long,
genre: String,
@@ -37,11 +37,15 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.R
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
@@ -78,6 +82,7 @@ enum class HomeSection(
fun HomeRoute(
onAlbumClick: (Album) -> Unit,
onArtistClick: (Artist) -> Unit,
onPlaylistClick: (Playlist) -> Unit,
onViewAlbum: (String) -> Unit,
onViewArtist: (String) -> Unit,
onMore: (HomeSection) -> Unit,
@@ -90,7 +95,7 @@ fun HomeRoute(
viewModel: HomeViewModel = koinViewModel(),
playbackController: PlaybackController = koinInject(),
) {
val feed by viewModel.feed.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
val playback by playbackController.state.collectAsStateWithLifecycle()
val actions =
ItemActions(
@@ -100,21 +105,41 @@ fun HomeRoute(
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
)
HomeScreen(
feed = feed,
coverArt = viewModel::coverArt,
playingSongId = playback.item?.id,
topInset = topInset,
bottomInset = bottomInset,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlayStarred = viewModel::playStarred,
onLongClick = { contextMenuHost?.show(it, actions) },
onMore = onMore,
onGenreClick = onGenreClick,
onDecadeClick = onDecadeClick,
onSync = viewModel::sync,
)
val onLongClick: (MenuTarget) -> Unit = { contextMenuHost?.show(it, actions) }
if (offline) {
OfflineHomeScreen(
albums = viewModel.downloadedAlbums.collectAsLazyPagingItems(),
artists = viewModel.downloadedArtists.collectAsLazyPagingItems(),
playlists = viewModel.downloadedPlaylists.collectAsLazyPagingItems(),
songs = viewModel.downloadedSongs.collectAsStateWithLifecycle(initialValue = emptyList()).value,
coverArt = viewModel::coverArt,
playingSongId = playback.item?.id,
topInset = topInset,
bottomInset = bottomInset,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick,
onSongPlay = viewModel::playDownloaded,
onLongClick = onLongClick,
)
} else {
val feed by viewModel.feed.collectAsStateWithLifecycle()
HomeScreen(
feed = feed,
coverArt = viewModel::coverArt,
playingSongId = playback.item?.id,
topInset = topInset,
bottomInset = bottomInset,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlayStarred = viewModel::playStarred,
onLongClick = onLongClick,
onMore = onMore,
onGenreClick = onGenreClick,
onDecadeClick = onDecadeClick,
onSync = viewModel::sync,
)
}
}
@Composable
@@ -152,24 +177,7 @@ fun HomeScreen(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(top = topInset + 8.dp, bottom = bottomInset + 24.dp),
) {
item(key = "home-title") {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(R.drawable.ic_subtracks_logo),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(width = 34.dp, height = 25.dp),
)
Spacer(Modifier.width(8.dp))
Text(text = "subtracks", style = MaterialTheme.typography.headlineMedium)
}
}
homeTitle()
albumRow(
HomeSection.RecentlyAddedAlbums,
feed.recentlyAddedAlbums,
@@ -233,6 +241,188 @@ fun HomeScreen(
}
}
private fun LazyListScope.homeTitle() {
item(key = "home-title") {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 0.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(R.drawable.ic_subtracks_logo),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(width = 34.dp, height = 25.dp),
)
Spacer(Modifier.width(8.dp))
Text(text = "subtracks", style = MaterialTheme.typography.headlineMedium)
}
}
}
@Composable
fun OfflineHomeScreen(
albums: LazyPagingItems<Album>,
artists: LazyPagingItems<Artist>,
playlists: LazyPagingItems<Playlist>,
songs: List<AlbumSongItem>,
coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?,
topInset: Dp = 0.dp,
bottomInset: Dp = 0.dp,
onAlbumClick: (Album) -> Unit = {},
onArtistClick: (Artist) -> Unit = {},
onPlaylistClick: (Playlist) -> Unit = {},
onSongPlay: (Song) -> Unit = {},
onLongClick: (MenuTarget) -> Unit = {},
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
LazyColumn(
modifier = modifier.fillMaxSize(),
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)
songList(
key = "downloaded-songs",
title = "Downloaded songs",
songs = songs,
coverArt = coverArt,
playingSongId = playingSongId,
onSongPlay = onSongPlay,
onLongClick = onLongClick,
onMore = null,
)
}
}
}
private fun LazyListScope.downloadedAlbumRow(
albums: LazyPagingItems<Album>,
coverArt: (String?, Boolean) -> CoverArtRef?,
onAlbumClick: (Album) -> Unit,
onLongClick: (MenuTarget) -> Unit,
) {
if (albums.itemCount == 0) return
item(key = "downloaded-albums-header") {
HomeSectionHeader("Downloaded albums", null)
}
item(key = "downloaded-albums-row") {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
) {
items(count = albums.itemCount, key = albums.itemKey { it.id }) { index ->
val album = albums[index]
if (album != null) {
val ref = coverArt(album.coverArt, true)
AlbumTile(
album = album,
ref = ref,
onClick = { onAlbumClick(album) },
onLongClick = { onLongClick(MenuTarget.Album(album, ref)) },
)
}
}
}
}
}
private fun LazyListScope.downloadedArtistRow(
artists: LazyPagingItems<Artist>,
coverArt: (String?, Boolean) -> CoverArtRef?,
onArtistClick: (Artist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
) {
if (artists.itemCount == 0) return
item(key = "downloaded-artists-header") {
HomeSectionHeader("Downloaded artists", null)
}
item(key = "downloaded-artists-row") {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(ARTIST_SPACING),
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
) {
items(count = artists.itemCount, key = artists.itemKey { it.id }) { index ->
val artist = artists[index]
if (artist != null) {
val ref = coverArt(artist.coverArt, true)
ArtistTile(
artist = artist,
ref = ref,
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, ref)) },
)
}
}
}
}
}
private fun LazyListScope.downloadedPlaylistRow(
playlists: LazyPagingItems<Playlist>,
coverArt: (String?, Boolean) -> CoverArtRef?,
onPlaylistClick: (Playlist) -> Unit,
onLongClick: (MenuTarget) -> Unit,
) {
if (playlists.itemCount == 0) return
item(key = "downloaded-playlists-header") {
HomeSectionHeader("Downloaded playlists", null)
}
item(key = "downloaded-playlists-row") {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(TILE_SPACING),
modifier = Modifier.padding(top = SECTION_CONTENT_TOP),
) {
items(count = playlists.itemCount, key = playlists.itemKey { it.id }) { index ->
val playlist = playlists[index]
if (playlist != null) {
val ref = coverArt(playlist.coverArt, true)
PlaylistTile(
playlist = playlist,
ref = ref,
onClick = { onPlaylistClick(playlist) },
onLongClick = { onLongClick(MenuTarget.Playlist(playlist, ref)) },
)
}
}
}
}
}
@Composable
private fun PlaylistTile(
playlist: Playlist,
ref: CoverArtRef?,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Column(
modifier = Modifier.width(TILE_SIZE).combinedClickable(onClick = onClick, onLongClick = onLongClick),
) {
CoverArt(
ref = ref,
name = playlist.name,
modifier = Modifier.size(TILE_SIZE).clip(RoundedCornerShape(2.dp)),
)
Spacer(Modifier.height(6.dp))
Text(
text = playlist.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
private fun HomeFeed.isEmpty(): Boolean =
recentlyPlayedAlbums.isEmpty() &&
recentlyPlayedArtists.isEmpty() &&
@@ -400,10 +590,30 @@ private fun LazyListScope.homeSongs(
onSongPlay: (Song) -> Unit,
onLongClick: (MenuTarget) -> Unit,
onMore: (HomeSection) -> Unit,
) = songList(
key = section.name,
title = section.title,
songs = songs,
coverArt = coverArt,
playingSongId = playingSongId,
onSongPlay = onSongPlay,
onLongClick = onLongClick,
onMore = { onMore(section) },
)
private fun LazyListScope.songList(
key: String,
title: String,
songs: List<AlbumSongItem>,
coverArt: (String?, Boolean) -> CoverArtRef?,
playingSongId: String?,
onSongPlay: (Song) -> Unit,
onLongClick: (MenuTarget) -> Unit,
onMore: (() -> Unit)?,
) {
if (songs.isEmpty()) return
item(key = "${section.name}-header") {
HomeSectionHeader(section.title) { onMore(section) }
item(key = "$key-header") {
HomeSectionHeader(title, onMore)
}
items(songs, key = { it.song.id }) { item ->
SongRow(
@@ -2,11 +2,18 @@ package com.subtracks.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
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.data.repo.rediscoverCutoff
@@ -15,6 +22,7 @@ import com.subtracks.data.sync.SyncStatus
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -52,6 +60,52 @@ class HomeViewModel(
.map { it == SyncStatus.Running }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
val offline: StateFlow<Boolean> =
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
private val activeSource = libraryRepository.activeSourceId.filterNotNull()
val downloadedAlbums: Flow<PagingData<Album>> =
activeSource
.flatMapLatest { id ->
libraryRepository.albums(
sourceId = id,
sort = AlbumSort.Added,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
}.cachedIn(viewModelScope)
val downloadedArtists: Flow<PagingData<Artist>> =
activeSource
.flatMapLatest { id ->
libraryRepository.artists(
sourceId = id,
sort = ArtistSort.Name,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
)
}.cachedIn(viewModelScope)
val downloadedPlaylists: Flow<PagingData<Playlist>> =
activeSource
.flatMapLatest { id ->
libraryRepository.playlists(
sourceId = id,
sort = PlaylistSort.Name,
descending = false,
search = "",
downloaded = true,
)
}.cachedIn(viewModelScope)
val downloadedSongs: Flow<List<AlbumSongItem>> =
activeSource.flatMapLatest { libraryRepository.downloadedSongs(it) }
val feed: StateFlow<HomeFeed> =
libraryRepository.activeSourceId
.filterNotNull()
@@ -105,6 +159,10 @@ class HomeViewModel(
viewModelScope.playStarredList(libraryRepository, playbackController, song)
}
fun playDownloaded(song: Song) {
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
}
fun sync() = syncManager.requestSync()
private data class PlayRows(
@@ -185,3 +243,14 @@ internal fun CoroutineScope.playGenreList(
playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0))
}
}
internal fun CoroutineScope.playDownloadedList(
libraryRepository: LibraryRepository,
playbackController: PlaybackController,
song: Song,
) {
launch {
val ids = libraryRepository.downloadedSongIds(song.sourceId)
playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0))
}
}
@@ -323,6 +323,7 @@ fun LibraryRoute(
HomeRoute(
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onPlaylistClick = onPlaylistClick,
onViewAlbum = onViewAlbum,
onViewArtist = onViewArtist,
onMore = onHomeMore,
@@ -9,7 +9,9 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
@@ -163,6 +165,30 @@ class LibraryHomeDaoTest {
)
}
@Test
fun downloadedSongsOnlyIncludeCompletedDownloadsWithCoverArt() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(listOf(album(sourceId, "al1", "Album", coverArt = "cover-1")))
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One"),
song(sourceId, "s2", "Two"),
song(sourceId, "s3", "Three"),
),
)
val downloads = db.downloadDao()
downloads.upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
downloads.upsert(SongDownload(sourceId, "s2", DownloadStatus.Queued))
downloads.upsert(SongDownload(sourceId, "s3", DownloadStatus.Completed))
val downloaded = dao.downloadedSongs(sourceId).first()
assertEquals(listOf("s1", "s3"), downloaded.map { it.song.id })
assertEquals("cover-1", downloaded.first().coverArt)
assertEquals(listOf("s1", "s3"), dao.downloadedSongIds(sourceId))
}
@Test
fun songsByGenreMatchesTheGenreRelationsWithFallback() =
runTest {
@@ -18,6 +18,7 @@ import com.github.takahirom.roborazzi.captureRoboImage
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.ui.theme.SubtracksTheme
import kotlinx.coroutines.flow.flowOf
@@ -143,6 +144,28 @@ class HomeScreenshotTest {
played = played,
)
@Test
fun homeOffline() {
composeRule.setContent {
SubtracksTheme {
Surface(modifier = Modifier.fillMaxSize()) {
OfflineHomeScreen(
albums = remember { flowOf(PagingData.from(listOf(listAlbum("Offline Album", 0)))) }.collectAsLazyPagingItems(),
artists = remember { flowOf(PagingData.from(listOf(listArtist("Offline Artist", 0)))) }.collectAsLazyPagingItems(),
playlists = remember { flowOf(PagingData.empty<Playlist>()) }.collectAsLazyPagingItems(),
songs = songs(),
coverArt = { _, _ -> null },
playingSongId = null,
)
}
}
}
composeRule.waitUntil(timeoutMillis = 5_000) {
composeRule.onAllNodesWithText("Downloaded albums").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/home_offline.png")
}
private fun listAlbum(
name: String,
created: Long,