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 57e54a21..8d7927da 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/LibraryDao.kt @@ -122,6 +122,18 @@ interface LibraryDao { albumId: String, ): Flow + @Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId") + fun artist( + sourceId: Long, + artistId: String, + ): Flow + + @Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name COLLATE NOCASE") + fun albumsForArtist( + sourceId: Long, + artistId: String, + ): Flow> + @Query("SELECT * FROM playlists WHERE sourceId = :sourceId AND id = :playlistId") fun playlist( sourceId: Long, 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 3672bb27..9f8df155 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/LibraryRepository.kt @@ -51,6 +51,16 @@ class LibraryRepository( albumId: String, ): Flow = db.libraryDao().album(sourceId, albumId) + fun artist( + sourceId: Long, + artistId: String, + ): Flow = db.libraryDao().artist(sourceId, artistId) + + fun artistAlbums( + sourceId: Long, + artistId: String, + ): Flow> = db.libraryDao().albumsForArtist(sourceId, artistId) + fun albumSongs( sourceId: Long, albumId: String, diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index 04f83ad8..a875dd6b 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -12,6 +12,7 @@ import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlayerConnection import com.subtracks.ui.RootViewModel import com.subtracks.ui.library.AlbumDetailViewModel +import com.subtracks.ui.library.ArtistDetailViewModel import com.subtracks.ui.library.LibraryViewModel import com.subtracks.ui.library.PlaylistDetailViewModel import com.subtracks.ui.playback.QueueViewModel @@ -39,6 +40,7 @@ fun appModule( viewModel { SettingsViewModel(get(), get()) } viewModel { AddSourceViewModel(get(), get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), params.get()) } + viewModel { params -> ArtistDetailViewModel(get(), get(), params.get()) } viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), params.get()) } viewModel { QueueViewModel(get(), get()) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt index f150069b..c8602397 100644 --- a/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt +++ b/app/src/main/kotlin/com/subtracks/ui/AppRoot.kt @@ -37,6 +37,7 @@ import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.LoadingState import com.subtracks.ui.library.AlbumDetailRoute +import com.subtracks.ui.library.ArtistDetailRoute import com.subtracks.ui.library.LibraryRoute import com.subtracks.ui.library.PlaylistDetailRoute import com.subtracks.ui.playback.MiniPlayer @@ -68,10 +69,13 @@ private object Routes { const val SETTINGS = "settings" const val ADD_SERVER = "add-server" const val ALBUM_DETAIL = "album/{albumId}" + const val ARTIST_DETAIL = "artist/{artistId}" const val PLAYLIST_DETAIL = "playlist/{playlistId}" fun album(id: String) = "album/${Uri.encode(id)}" + fun artist(id: String) = "artist/${Uri.encode(id)}" + fun playlist(id: String) = "playlist/${Uri.encode(id)}" } @@ -132,6 +136,7 @@ private fun MainNavigation() { composable(Routes.LIBRARY) { LibraryRoute( onAlbumClick = { album -> navController.navigate(Routes.album(album.id)) }, + onArtistClick = { artist -> navController.navigate(Routes.artist(artist.id)) }, onPlaylistClick = { playlist -> navController.navigate(Routes.playlist(playlist.id)) }, onOpenSettings = { navController.navigate(Routes.SETTINGS) }, bottomInset = if (playerVisible) 0.dp else navBarInset, @@ -152,6 +157,16 @@ private fun MainNavigation() { onBack = { navController.popBackStack() }, ) } + composable( + route = Routes.ARTIST_DETAIL, + arguments = listOf(navArgument("artistId") { type = NavType.StringType }), + ) { entry -> + ArtistDetailRoute( + artistId = entry.arguments?.getString("artistId").orEmpty(), + onBack = { navController.popBackStack() }, + onAlbumClick = { album -> navController.navigate(Routes.album(album.id)) }, + ) + } composable( route = Routes.PLAYLIST_DETAIL, arguments = listOf(navArgument("playlistId") { type = NavType.StringType }), diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt new file mode 100644 index 00000000..a4d279e7 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -0,0 +1,130 @@ +package com.subtracks.ui.library + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.CoverArtRef +import com.subtracks.ui.components.CoverArt +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf + +@Composable +fun ArtistDetailRoute( + artistId: String, + onBack: () -> Unit, + onAlbumClick: (Album) -> Unit, + viewModel: ArtistDetailViewModel = koinViewModel(key = artistId) { parametersOf(artistId) }, +) { + val artist by viewModel.artist.collectAsStateWithLifecycle(initialValue = null) + val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList()) + ArtistDetailScreen( + artist = artist, + albums = albums, + coverArt = viewModel::coverArt, + onBack = onBack, + onAlbumClick = onAlbumClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArtistDetailScreen( + artist: Artist?, + albums: List, + coverArt: (String?, Boolean) -> CoverArtRef?, + onBack: () -> Unit, + onAlbumClick: (Album) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(artist?.name.orEmpty(), maxLines = 1) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + LazyVerticalGrid( + columns = GridCells.Fixed(3), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(padding).fillMaxSize(), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CoverArt( + ref = coverArt(artist?.coverArt, false), + name = artist?.name.orEmpty(), + modifier = Modifier.size(120.dp).clip(CircleShape), + ) + Column { + Text(artist?.name.orEmpty(), style = MaterialTheme.typography.titleLarge) + Text( + text = "${albums.size} albums", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + items(albums, key = { it.id }) { album -> + Column(Modifier.clickable { onAlbumClick(album) }) { + CoverArt( + ref = coverArt(album.coverArt, true), + name = album.name, + modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)), + ) + Text( + text = album.name, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt new file mode 100644 index 00000000..3b309589 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -0,0 +1,30 @@ +package com.subtracks.ui.library + +import androidx.lifecycle.ViewModel +import com.subtracks.data.model.Album +import com.subtracks.data.model.Artist +import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.repo.LibraryRepository +import com.subtracks.data.repo.SourceRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest + +@OptIn(ExperimentalCoroutinesApi::class) +class ArtistDetailViewModel( + libraryRepository: LibraryRepository, + private val sourceRepository: SourceRepository, + private val artistId: String, +) : ViewModel() { + private val sourceId = libraryRepository.activeSourceId.filterNotNull() + + val artist: Flow = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) } + + val albums: Flow> = sourceId.flatMapLatest { libraryRepository.artistAlbums(it, artistId) } + + fun coverArt( + coverArt: String?, + thumbnail: Boolean, + ): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail) +} diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt index 88f96fee..043e0296 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -1,5 +1,6 @@ package com.subtracks.ui.library +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size @@ -29,6 +30,7 @@ fun ArtistsContent( items: LazyPagingItems, coverArt: (String?, Boolean) -> CoverArtRef?, bottomInset: Dp, + onArtistClick: (Artist) -> Unit, modifier: Modifier = Modifier, ) { when { @@ -70,6 +72,7 @@ fun ArtistsContent( modifier = Modifier.size(48.dp).clip(CircleShape), ) }, + modifier = Modifier.clickable { onArtistClick(artist) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) } 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 20367600..bec01907 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -86,6 +86,7 @@ enum class LibraryTab( @Composable fun LibraryRoute( onAlbumClick: (Album) -> Unit, + onArtistClick: (Artist) -> Unit, onPlaylistClick: (Playlist) -> Unit, onOpenSettings: () -> Unit, bottomInset: Dp, @@ -105,6 +106,7 @@ fun LibraryRoute( playlists = viewModel.playlists.collectAsLazyPagingItems(), coverArt = viewModel::coverArt, onAlbumClick = onAlbumClick, + onArtistClick = onArtistClick, onPlaylistClick = onPlaylistClick, onSongClick = viewModel::playSong, onSync = viewModel::sync, @@ -124,6 +126,7 @@ fun LibraryScreen( playlists: LazyPagingItems, coverArt: (String?, Boolean) -> CoverArtRef?, onAlbumClick: (Album) -> Unit, + onArtistClick: (Artist) -> Unit, onPlaylistClick: (Playlist) -> Unit, onSongClick: (Int) -> Unit, onSync: () -> Unit, @@ -210,7 +213,7 @@ fun LibraryScreen( ) { page -> when (LibraryTab.entries[page]) { LibraryTab.Albums -> AlbumsContent(albums, coverArt, bottomInset, onAlbumClick) - LibraryTab.Artists -> ArtistsContent(artists, coverArt, bottomInset) + LibraryTab.Artists -> ArtistsContent(artists, coverArt, bottomInset, onArtistClick) LibraryTab.Songs -> SongsContent(songs, coverArt, bottomInset, onSongClick, playingSongId) LibraryTab.Playlists -> PlaylistsContent(playlists, coverArt, bottomInset, onPlaylistClick) } diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 47934b1a..34864397 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -38,6 +38,7 @@ import com.subtracks.playback.PlaybackState import com.subtracks.playback.QueueItem import com.subtracks.ui.library.ALBUM_COVER_TAG import com.subtracks.ui.library.AlbumDetailScreen +import com.subtracks.ui.library.ArtistDetailScreen import com.subtracks.ui.library.LibraryScreen import com.subtracks.ui.library.LibraryTab import com.subtracks.ui.library.PlaylistDetailScreen @@ -130,6 +131,24 @@ class AppScreenshotTest { composeRule.onRoot().captureRoboImage("src/test/screenshots/album_detail.png") } + @Test + fun artistDetail() { + composeRule.setContent { + SubtracksTheme { + ArtistDetailScreen( + artist = Fixtures.artists.first(), + albums = Fixtures.albums.filter { it.artistId == Fixtures.artists.first().id }, + coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, + onBack = {}, + onAlbumClick = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } + awaitText("Kid A") + composeRule.onRoot().captureRoboImage("src/test/screenshots/artist_detail.png") + } + @Test fun playlistDetail() { composeRule.setContent { @@ -276,6 +295,7 @@ class AppScreenshotTest { playlists = remember { flowOf(PagingData.from(Fixtures.playlists)) }.collectAsLazyPagingItems(), coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } }, onAlbumClick = {}, + onArtistClick = {}, onPlaylistClick = {}, onSongClick = {}, onSync = {}, diff --git a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt index 9a80bdfd..ec329ec0 100644 --- a/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/LibraryScreenTest.kt @@ -49,6 +49,7 @@ class LibraryScreenTest { playlists = remember { flowOf(PagingData.empty()) }.collectAsLazyPagingItems(), coverArt = { _, _ -> null }, onAlbumClick = {}, + onArtistClick = {}, onPlaylistClick = {}, onSongClick = {}, onSync = {}, diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 05885d15..9538f1e5 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -24,9 +24,10 @@ Goal: a usable app for browsing a server, end to end. - App shell: a root gate that shows the add-server flow until a server exists, then the tabbed library screen matching the Flutter app (icon-only tabs for albums, artists, songs and playlists, with sync and settings actions), plus album and playlist detail screens and a monochrome theme. - Source setup: name/address/username/password with token-auth toggle, a connection test, save-and-sync, plus server switching and removal in settings. - Library browsing: albums (covers-only grid), artists, songs and playlists paged from Room (`PagingSource`) and rendered with `LazyPagingItems`, with Coil cover art served from stable media URLs. -- Roborazzi screenshots for the eight new screens, rendered on demand for review (not committed). +- Artist detail: tapping an artist opens their albums, newest first, and an album can be opened from there. +- Roborazzi screenshots for the new screens, rendered on demand for review (not committed). -Not done yet in this slice (deliberately): artist detail, cover-art tonal colour extraction, long-press menus, editing an existing server, and per-section sort/filter controls (only album sort is wired). +Not done yet in this slice (deliberately): cover-art tonal colour extraction, long-press menus, editing an existing server, and per-section sort/filter controls (only album sort is wired). ## Phase 3 - Playback (in progress)