open an artist's albums from the artists tab

This commit is contained in:
austinried
2026-10-04 00:29:52 +00:00
parent 70ffdf7ce9
commit 53875b3640
11 changed files with 230 additions and 3 deletions
@@ -122,6 +122,18 @@ interface LibraryDao {
albumId: String,
): Flow<Album?>
@Query("SELECT * FROM artists WHERE sourceId = :sourceId AND id = :artistId")
fun artist(
sourceId: Long,
artistId: String,
): Flow<Artist?>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY year DESC, name COLLATE NOCASE")
fun albumsForArtist(
sourceId: Long,
artistId: String,
): Flow<List<Album>>
@Query("SELECT * FROM playlists WHERE sourceId = :sourceId AND id = :playlistId")
fun playlist(
sourceId: Long,
@@ -51,6 +51,16 @@ class LibraryRepository(
albumId: String,
): Flow<Album?> = db.libraryDao().album(sourceId, albumId)
fun artist(
sourceId: Long,
artistId: String,
): Flow<Artist?> = db.libraryDao().artist(sourceId, artistId)
fun artistAlbums(
sourceId: Long,
artistId: String,
): Flow<List<Album>> = db.libraryDao().albumsForArtist(sourceId, artistId)
fun albumSongs(
sourceId: Long,
albumId: String,
@@ -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()) }
}
@@ -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 }),
@@ -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<Album>,
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),
)
}
}
}
}
}
@@ -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<Artist?> = sourceId.flatMapLatest { libraryRepository.artist(it, artistId) }
val albums: Flow<List<Album>> = sourceId.flatMapLatest { libraryRepository.artistAlbums(it, artistId) }
fun coverArt(
coverArt: String?,
thumbnail: Boolean,
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
}
@@ -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<Artist>,
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),
)
}
@@ -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<Playlist>,
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)
}
@@ -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 = {},
@@ -49,6 +49,7 @@ class LibraryScreenTest {
playlists = remember { flowOf(PagingData.empty<Playlist>()) }.collectAsLazyPagingItems(),
coverArt = { _, _ -> null },
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSongClick = {},
onSync = {},
+3 -2
View File
@@ -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)