mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
open an artist's albums from the artists tab
This commit is contained in:
@@ -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 = {},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user