From c44987ff31fe50ac84fdec30c0457d46f95fd513 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:06:23 +0000 Subject: [PATCH] home: swap to downloaded sections in offline mode --- .../com/subtracks/data/db/LibraryDao.kt | 17 ++ .../subtracks/data/repo/LibraryRepository.kt | 4 + .../com/subtracks/ui/home/HomeScreen.kt | 282 +++++++++++++++--- .../com/subtracks/ui/home/HomeViewModel.kt | 69 +++++ .../com/subtracks/ui/library/LibraryScreen.kt | 1 + .../subtracks/data/db/LibraryHomeDaoTest.kt | 26 ++ .../subtracks/ui/home/HomeScreenshotTest.kt | 23 ++ 7 files changed, 386 insertions(+), 36 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 c40f066b..bdcb9225 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -710,6 +710,23 @@ 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 " + + "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> + + @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 + @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 550bd9d4..bbbcee8c 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,10 @@ class LibraryRepository( suspend fun starredSongIds(sourceId: Long): List = db.libraryDao().starredSongIds(sourceId) + fun downloadedSongs(sourceId: Long): Flow> = db.libraryDao().downloadedSongs(sourceId) + + suspend fun downloadedSongIds(sourceId: Long): List = db.libraryDao().downloadedSongIds(sourceId) + fun songsByGenre( sourceId: Long, genre: String, 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 237ce7f5..430026ec 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeScreen.kt @@ -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, + artists: LazyPagingItems, + playlists: LazyPagingItems, + songs: List, + 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, + 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, + 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, + 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, + 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( 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 75e7ada3..73db44a8 100644 --- a/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/home/HomeViewModel.kt @@ -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 = + sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + private val activeSource = libraryRepository.activeSourceId.filterNotNull() + + val downloadedAlbums: Flow> = + activeSource + .flatMapLatest { id -> + libraryRepository.albums( + sourceId = id, + sort = AlbumSort.Added, + descending = false, + starred = StarredFilter.Any, + search = "", + downloaded = true, + ) + }.cachedIn(viewModelScope) + + val downloadedArtists: Flow> = + activeSource + .flatMapLatest { id -> + libraryRepository.artists( + sourceId = id, + sort = ArtistSort.Name, + descending = false, + starred = StarredFilter.Any, + search = "", + downloaded = true, + ) + }.cachedIn(viewModelScope) + + val downloadedPlaylists: Flow> = + activeSource + .flatMapLatest { id -> + libraryRepository.playlists( + sourceId = id, + sort = PlaylistSort.Name, + descending = false, + search = "", + downloaded = true, + ) + }.cachedIn(viewModelScope) + + val downloadedSongs: Flow> = + activeSource.flatMapLatest { libraryRepository.downloadedSongs(it) } + val feed: StateFlow = 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)) + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index 75a0eaf0..62eb5108 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -323,6 +323,7 @@ fun LibraryRoute( HomeRoute( onAlbumClick = onAlbumClick, onArtistClick = onArtistClick, + onPlaylistClick = onPlaylistClick, onViewAlbum = onViewAlbum, onViewArtist = onViewArtist, onMore = onHomeMore, 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 cd48131a..654b969e 100644 --- a/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/db/LibraryHomeDaoTest.kt @@ -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 { 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 9f948f23..501b1145 100644 --- a/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/home/HomeScreenshotTest.kt @@ -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()) }.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,