diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistDao.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistDao.kt index 342f989..5604d01 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistDao.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/local/playlist/PlaylistDao.kt @@ -13,6 +13,10 @@ data class PlaylistWithCount( val trackCount: Int, ) +/** One (playlist, track) membership row - the raw shape the Downloads collection needs to work + * out which playlists contain a downloaded track, without loading every playlist's full track list. */ +data class PlaylistTrackMembership(val playlistLocalId: String, val trackId: String) + @Dao interface PlaylistDao { @Query( @@ -51,6 +55,9 @@ interface PlaylistDao { @Query("SELECT * FROM playlist_tracks WHERE playlistLocalId = :localId ORDER BY position ASC") fun observeTracks(localId: String): Flow> + @Query("SELECT playlistLocalId, trackId FROM playlist_tracks") + fun observeAllMemberships(): Flow> + @Query("SELECT * FROM playlist_tracks WHERE playlistLocalId = :localId ORDER BY position ASC") suspend fun getTracks(localId: String): List diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistRepository.kt b/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistRepository.kt index 983d3c4..ca70099 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistRepository.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/data/playlist/PlaylistRepository.kt @@ -62,6 +62,12 @@ class PlaylistRepository @Inject constructor( fun observeTracks(localId: String): Flow> = playlistDao.observeTracks(localId).map { list -> list.map { it.toDomain() } } + /** playlistId -> the set of track ids it contains, for the Downloads collection to work out + * which playlists have a downloaded track without loading every playlist's full track list. */ + fun observeTrackIdsByPlaylist(): Flow>> = + playlistDao.observeAllMemberships() + .map { rows -> rows.groupBy({ it.playlistLocalId }, { it.trackId }).mapValues { it.value.toSet() } } + suspend fun createPlaylist(name: String, initialTracks: List = emptyList()): String { val localId = UUID.randomUUID().toString() val now = System.currentTimeMillis() diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsScreen.kt index c65c28e..fb7e88d 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -12,6 +13,7 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -21,6 +23,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -28,6 +31,7 @@ import androidx.media3.common.util.UnstableApi import com.InfernalAquatics.deepwave.R import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity +import com.InfernalAquatics.deepwave.data.model.Playlist import com.InfernalAquatics.deepwave.data.network.coverArtUrl import com.InfernalAquatics.deepwave.ui.components.TrackRow import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme @@ -36,12 +40,14 @@ import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme @Composable fun DownloadsScreen( onBack: () -> Unit, + onPlaylistClick: (String) -> Unit, viewModel: DownloadsViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsState() DownloadsContent( uiState = uiState, onBack = onBack, + onPlaylistClick = onPlaylistClick, onTrackClick = viewModel::playTrack, onRemove = viewModel::remove, ) @@ -52,6 +58,7 @@ fun DownloadsScreen( private fun DownloadsContent( uiState: DownloadsUiState, onBack: () -> Unit, + onPlaylistClick: (String) -> Unit, onTrackClick: (Int) -> Unit, onRemove: (String) -> Unit, ) { @@ -67,40 +74,56 @@ private fun DownloadsContent( ) }, ) { innerPadding -> - if (uiState.tracks.isEmpty()) { + if (uiState.downloadedPlaylists.isEmpty() && uiState.looseTracks.isEmpty()) { Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + modifier = Modifier.fillMaxSize().padding(innerPadding), contentAlignment = Alignment.Center, ) { Text(text = stringResource(R.string.downloads_empty), style = MaterialTheme.typography.bodyLarge) } } else { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - itemsIndexed(uiState.tracks, key = { _, track -> track.trackId }) { index, track -> - TrackRow( - title = track.title, - subtitle = track.artistName.orEmpty(), - coverArtUrl = coverArtUrl(track.coverArtId), - onClick = { onTrackClick(index) }, - trailingContent = { - IconButton(onClick = { onRemove(track.trackId) }) { - Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.download_remove)) - } - }, - ) + LazyColumn(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + if (uiState.downloadedPlaylists.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.downloads_section_playlists)) } + items(uiState.downloadedPlaylists, key = { "playlist:${it.id}" }) { playlist -> + TrackRow( + title = playlist.name, + subtitle = pluralStringResource(R.plurals.playlist_track_count, playlist.trackCount, playlist.trackCount), + coverArtUrl = coverArtUrl(playlist.coverArtId), + onClick = { onPlaylistClick(playlist.id) }, + ) + } + } + if (uiState.looseTracks.isNotEmpty()) { + item { SectionHeader(stringResource(R.string.downloads_section_tracks)) } + itemsIndexed(uiState.looseTracks, key = { _, track -> "track:${track.trackId}" }) { index, track -> + TrackRow( + title = track.title, + subtitle = track.artistName.orEmpty(), + coverArtUrl = coverArtUrl(track.coverArtId), + onClick = { onTrackClick(index) }, + trailingContent = { + IconButton(onClick = { onRemove(track.trackId) }) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.download_remove)) + } + }, + ) + } } } } } } -private val sampleTracks = List(3) { index -> +@Composable +private fun SectionHeader(title: String) { + ListItem(headlineContent = { Text(title, style = MaterialTheme.typography.titleMedium) }) +} + +private val samplePlaylists = listOf( + Playlist(id = "1", name = "Riddim Favs", coverArtId = null, trackCount = 12, isSynced = true), +) +private val sampleTracks = List(2) { index -> DownloadedTrackEntity( trackId = "sample-$index", title = "Sample Track ${index + 1}", @@ -120,7 +143,13 @@ private val sampleTracks = List(3) { index -> @Composable private fun DownloadsContentPreview() { DeepwaveTheme { - DownloadsContent(uiState = DownloadsUiState(tracks = sampleTracks), onBack = {}, onTrackClick = {}, onRemove = {}) + DownloadsContent( + uiState = DownloadsUiState(downloadedPlaylists = samplePlaylists, looseTracks = sampleTracks), + onBack = {}, + onPlaylistClick = {}, + onTrackClick = {}, + onRemove = {}, + ) } } @@ -128,6 +157,6 @@ private fun DownloadsContentPreview() { @Composable private fun DownloadsContentEmptyPreview() { DeepwaveTheme { - DownloadsContent(uiState = DownloadsUiState(), onBack = {}, onTrackClick = {}, onRemove = {}) + DownloadsContent(uiState = DownloadsUiState(), onBack = {}, onPlaylistClick = {}, onTrackClick = {}, onRemove = {}) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsViewModel.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsViewModel.kt index cd003dc..c758da9 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsViewModel.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/downloads/DownloadsViewModel.kt @@ -6,31 +6,56 @@ import androidx.media3.common.util.UnstableApi import com.InfernalAquatics.deepwave.data.download.DownloadRepository import com.InfernalAquatics.deepwave.data.local.download.DownloadStatus import com.InfernalAquatics.deepwave.data.local.download.DownloadedTrackEntity +import com.InfernalAquatics.deepwave.data.model.Playlist +import com.InfernalAquatics.deepwave.data.playlist.PlaylistRepository import com.InfernalAquatics.deepwave.media.PlaybackController import com.InfernalAquatics.deepwave.media.toMediaItem import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject -data class DownloadsUiState(val tracks: List = emptyList()) +/** + * [downloadedPlaylists]: any playlist with at least one downloaded track. [looseTracks]: + * downloaded tracks that aren't a member of any playlist - a track belonging to a playlist is + * never listed twice, since that playlist already qualifies for [downloadedPlaylists] by virtue + * of this same track being downloaded. + */ +data class DownloadsUiState( + val downloadedPlaylists: List = emptyList(), + val looseTracks: List = emptyList(), +) @UnstableApi @HiltViewModel class DownloadsViewModel @Inject constructor( private val downloadRepository: DownloadRepository, + private val playlistRepository: PlaylistRepository, private val playbackController: PlaybackController, ) : ViewModel() { - val uiState: StateFlow = downloadRepository.downloadedTracks() - .map { tracks -> DownloadsUiState(tracks.filter { it.status == DownloadStatus.COMPLETE }) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) + val uiState: StateFlow = combine( + downloadRepository.downloadedTracks(), + playlistRepository.observePlaylists(), + playlistRepository.observeTrackIdsByPlaylist(), + ) { tracks, playlists, trackIdsByPlaylist -> + val downloaded = tracks.filter { it.status == DownloadStatus.COMPLETE } + val downloadedIds = downloaded.map { it.trackId }.toSet() + + val downloadedPlaylists = playlists.filter { playlist -> + trackIdsByPlaylist[playlist.id].orEmpty().any { it in downloadedIds } + } + val trackIdsInAnyPlaylist = trackIdsByPlaylist.values.flatten().toSet() + val looseTracks = downloaded.filter { it.trackId !in trackIdsInAnyPlaylist } + + DownloadsUiState(downloadedPlaylists, looseTracks) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) fun playTrack(index: Int) { - val tracks = uiState.value.tracks + val tracks = uiState.value.looseTracks if (index !in tracks.indices) return viewModelScope.launch { playbackController.ensureConnected() diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt index 6733e65..1202484 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/library/LibraryScreen.kt @@ -31,6 +31,7 @@ private enum class LibrarySection(val labelRes: Int) { fun LibraryScreen( onArtistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit, + onOpenDownloads: () -> Unit, modifier: Modifier = Modifier, ) { var section by remember { mutableStateOf(LibrarySection.Playlists) } @@ -47,7 +48,11 @@ fun LibraryScreen( } } when (section) { - LibrarySection.Playlists -> PlaylistsScreen(onPlaylistClick = onPlaylistClick, modifier = Modifier.weight(1f)) + LibrarySection.Playlists -> PlaylistsScreen( + onPlaylistClick = onPlaylistClick, + onOpenDownloads = onOpenDownloads, + modifier = Modifier.weight(1f), + ) LibrarySection.Artists -> ArtistsScreen(onArtistClick = onArtistClick, modifier = Modifier.weight(1f)) } } @@ -57,6 +62,6 @@ fun LibraryScreen( @Composable private fun LibraryScreenPreview() { DeepwaveTheme { - LibraryScreen(onArtistClick = {}, onPlaylistClick = {}) + LibraryScreen(onArtistClick = {}, onPlaylistClick = {}, onOpenDownloads = {}) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt index b5a7bb8..deb699d 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/DeepwaveNavHost.kt @@ -95,6 +95,7 @@ fun DeepwaveNavHost( onOpenArtist = { artistId -> navController.navigate(Route.ArtistDetail(artistId)) }, onOpenAlbum = { albumId -> navController.navigate(Route.AlbumDetail(albumId)) }, onOpenPlaylist = { playlistId -> navController.navigate(Route.PlaylistDetail(playlistId)) }, + onOpenDownloads = { navController.navigate(Route.Downloads) }, ) } composable { @@ -105,11 +106,13 @@ fun DeepwaveNavHost( popUpTo { inclusive = true } } }, - onOpenDownloads = { navController.navigate(Route.Downloads) }, ) } composable { - DownloadsScreen(onBack = { navController.popBackStack() }) + DownloadsScreen( + onBack = { navController.popBackStack() }, + onPlaylistClick = { playlistId -> navController.navigate(Route.PlaylistDetail(playlistId)) }, + ) } composable { ArtistDetailScreen( diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt index 105cd0d..37861c5 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/navigation/MainScreen.kt @@ -41,6 +41,7 @@ fun MainScreen( onOpenArtist: (String) -> Unit, onOpenAlbum: (String) -> Unit, onOpenPlaylist: (String) -> Unit, + onOpenDownloads: () -> Unit, ) { var selectedTab by remember { mutableStateOf(BottomTab.Home) } @@ -67,7 +68,11 @@ fun MainScreen( when (selectedTab) { BottomTab.Home -> HomeScreen(onAlbumClick = onOpenAlbum) BottomTab.Search -> SearchScreen(onArtistClick = onOpenArtist, onAlbumClick = onOpenAlbum) - BottomTab.Library -> LibraryScreen(onArtistClick = onOpenArtist, onPlaylistClick = onOpenPlaylist) + BottomTab.Library -> LibraryScreen( + onArtistClick = onOpenArtist, + onPlaylistClick = onOpenPlaylist, + onOpenDownloads = onOpenDownloads, + ) } } } @@ -78,6 +83,6 @@ fun MainScreen( @Composable private fun MainScreenPreview() { DeepwaveTheme { - MainScreen(onOpenSettings = {}, onOpenArtist = {}, onOpenAlbum = {}, onOpenPlaylist = {}) + MainScreen(onOpenSettings = {}, onOpenArtist = {}, onOpenAlbum = {}, onOpenPlaylist = {}, onOpenDownloads = {}) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsScreen.kt index deefab4..4f9b0bc 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/playlist/PlaylistsScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Download import androidx.compose.material3.Icon import androidx.compose.material3.ListItem import androidx.compose.material3.Text @@ -27,6 +28,7 @@ import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme @Composable fun PlaylistsScreen( onPlaylistClick: (String) -> Unit, + onOpenDownloads: () -> Unit, modifier: Modifier = Modifier, viewModel: PlaylistsViewModel = hiltViewModel(), ) { @@ -34,6 +36,7 @@ fun PlaylistsScreen( PlaylistsContent( uiState = uiState, onPlaylistClick = onPlaylistClick, + onOpenDownloads = onOpenDownloads, onNewPlaylist = viewModel::openCreate, modifier = modifier, ) @@ -47,6 +50,7 @@ fun PlaylistsScreen( private fun PlaylistsContent( uiState: PlaylistsUiState, onPlaylistClick: (String) -> Unit, + onOpenDownloads: () -> Unit, onNewPlaylist: () -> Unit, modifier: Modifier = Modifier, ) { @@ -58,6 +62,17 @@ private fun PlaylistsContent( modifier = Modifier.clickable(onClick = onNewPlaylist), ) } + item { + // Pinned, not a real Room playlist: its membership is computed live from what's + // actually on disk (see DownloadsViewModel), so it never drifts from reality and + // never syncs to the server - downloads are inherently per-device. + ListItem( + headlineContent = { Text(stringResource(R.string.downloads_title)) }, + supportingContent = { Text(stringResource(R.string.downloads_subtitle)) }, + leadingContent = { Icon(Icons.Filled.Download, contentDescription = null) }, + modifier = Modifier.clickable(onClick = onOpenDownloads), + ) + } if (uiState.playlists.isEmpty()) { item { ListItem(headlineContent = { Text(stringResource(R.string.playlist_empty)) }) @@ -84,7 +99,12 @@ private val samplePlaylists = listOf( @Composable private fun PlaylistsContentPreview() { DeepwaveTheme { - PlaylistsContent(uiState = PlaylistsUiState(playlists = samplePlaylists), onPlaylistClick = {}, onNewPlaylist = {}) + PlaylistsContent( + uiState = PlaylistsUiState(playlists = samplePlaylists), + onPlaylistClick = {}, + onOpenDownloads = {}, + onNewPlaylist = {}, + ) } } @@ -92,6 +112,6 @@ private fun PlaylistsContentPreview() { @Composable private fun PlaylistsContentEmptyPreview() { DeepwaveTheme { - PlaylistsContent(uiState = PlaylistsUiState(), onPlaylistClick = {}, onNewPlaylist = {}) + PlaylistsContent(uiState = PlaylistsUiState(), onPlaylistClick = {}, onOpenDownloads = {}, onNewPlaylist = {}) } } diff --git a/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt b/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt index f67b1e3..ef8511a 100644 --- a/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/InfernalAquatics/deepwave/ui/settings/SettingsScreen.kt @@ -34,7 +34,6 @@ import com.InfernalAquatics.deepwave.ui.theme.DeepwaveTheme fun SettingsScreen( onBack: () -> Unit, onLoggedOut: () -> Unit, - onOpenDownloads: () -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsState() @@ -46,7 +45,6 @@ fun SettingsScreen( defaultBitrate = defaultBitrate, onBack = onBack, onLogOutClick = { viewModel.logout(onLoggedOut) }, - onOpenDownloads = onOpenDownloads, onWifiOnlyChange = viewModel::setWifiOnly, onOpenDefaultQualityPicker = viewModel::openDefaultQualityPicker, onDismissDefaultQualityPicker = viewModel::dismissDefaultQualityPicker, @@ -62,7 +60,6 @@ private fun SettingsContent( defaultBitrate: BitrateOption, onBack: () -> Unit, onLogOutClick: () -> Unit, - onOpenDownloads: () -> Unit, onWifiOnlyChange: (Boolean) -> Unit, onOpenDefaultQualityPicker: () -> Unit, onDismissDefaultQualityPicker: () -> Unit, @@ -81,12 +78,6 @@ private fun SettingsContent( }, ) { innerPadding -> Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - ListItem( - headlineContent = { Text(stringResource(R.string.settings_downloads)) }, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onOpenDownloads), - ) ListItem( headlineContent = { Text(stringResource(R.string.settings_wifi_only_downloads)) }, trailingContent = { Switch(checked = wifiOnly, onCheckedChange = onWifiOnlyChange) }, @@ -124,7 +115,6 @@ private fun SettingsContentPreview() { defaultBitrate = BitrateOption.Normal, onBack = {}, onLogOutClick = {}, - onOpenDownloads = {}, onWifiOnlyChange = {}, onOpenDefaultQualityPicker = {}, onDismissDefaultQualityPicker = {}, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c65ae5a..c0bef8a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,7 +17,6 @@ Settings Log out - Downloads Download over Wi-Fi only Default download quality @@ -33,6 +32,9 @@ Downloads No downloads yet + Available offline + Playlists + Tracks Nothing playing Play