Release: web UX overhaul + Android native port + server polish #59

Merged
bvandeusen merged 298 commits from dev into main 2026-06-01 16:11:21 -04:00
6 changed files with 49 additions and 16 deletions
Showing only changes of commit d7dda2fcef - Show all commits
@@ -64,11 +64,12 @@ fun AlbumDetailWire.toAlbumEntity(): CachedAlbumEntity =
// source of truth for cache-first reads; mapping to a domain type here // source of truth for cache-first reads; mapping to a domain type here
// keeps Room types out of the UI layer. // keeps Room types out of the UI layer.
fun CachedArtistEntity.toDomain(): ArtistRef = fun CachedArtistEntity.toDomain(coverAlbumId: String = ""): ArtistRef =
ArtistRef( ArtistRef(
id = id, id = id,
name = name, name = name,
sortName = sortName, sortName = sortName,
coverAlbumId = coverAlbumId,
) )
fun CachedAlbumEntity.toDomain(artistName: String = ""): AlbumRef = fun CachedAlbumEntity.toDomain(artistName: String = ""): AlbumRef =
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.create import retrofit2.create
@@ -43,8 +44,17 @@ class LibraryRepository @Inject constructor(
// ---- Reads (Flow, cache-first; the cache is the source of truth) ---- // ---- Reads (Flow, cache-first; the cache is the source of truth) ----
// Joins each artist to its first cached album (by sort title) so the
// tile cover derives from /api/albums/{id}/cover — cached_artists
// stores no cover and sync carries none. Mirrors Flutter's
// artistTileProvider JOIN.
fun observeArtists(): Flow<List<ArtistRef>> = fun observeArtists(): Flow<List<ArtistRef>> =
artistDao.observeAll().map { rows -> rows.map { it.toDomain() } } combine(artistDao.observeAll(), albumDao.observeAll()) { artists, albums ->
val firstAlbumByArtist = albums
.groupBy { it.artistId }
.mapValues { (_, list) -> list.minByOrNull { it.sortTitle }?.id ?: "" }
artists.map { it.toDomain(coverAlbumId = firstAlbumByArtist[it.id] ?: "") }
}
fun observeAlbums(): Flow<List<AlbumRef>> = fun observeAlbums(): Flow<List<AlbumRef>> =
albumDao.observeAll().map { rows -> rows.map { it.toDomain() } } albumDao.observeAll().map { rows -> rows.map { it.toDomain() } }
@@ -19,15 +19,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.User import com.composables.icons.lucide.User
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.ServerImage
/** /**
* One artist tile. Circular cover; name centered beneath. Tap fires * One artist tile. Circular cover; name centered beneath. Tap fires
@@ -61,20 +60,17 @@ fun ArtistCard(
.clip(CircleShape), .clip(CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (artist.coverUrl.isEmpty()) { ServerImage(
url = artist.displayCoverUrl,
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
) {
Icon( Icon(
imageVector = Lucide.User, imageVector = Lucide.User,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(56.dp), modifier = Modifier.size(56.dp),
) )
} else {
AsyncImage(
model = artist.coverUrl,
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -12,6 +12,7 @@ import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -70,9 +71,12 @@ class MetadataProvider @Inject constructor(
.onEach { row -> if (row == null) fetchAlbum(id) } .onEach { row -> if (row == null) fetchAlbum(id) }
fun observeArtist(id: String): Flow<ArtistRef?> = fun observeArtist(id: String): Flow<ArtistRef?> =
artistDao.observeById(id) combine(
.map { it?.toDomain() } artistDao.observeById(id),
.onEach { row -> if (row == null) fetchArtist(id) } albumDao.observeByArtist(id),
) { artist, albums ->
artist?.toDomain(coverAlbumId = albums.minByOrNull { it.sortTitle }?.id ?: "")
}.onEach { row -> if (row == null) fetchArtist(id) }
fun observeTrack(id: String): Flow<TrackRef?> = fun observeTrack(id: String): Flow<TrackRef?> =
trackDao.observeById(id) trackDao.observeById(id)
@@ -14,4 +14,23 @@ data class ArtistRef(
val sortName: String = "", val sortName: String = "",
val albumCount: Int = 0, val albumCount: Int = 0,
val coverUrl: String = "", val coverUrl: String = "",
) val coverAlbumId: String = "",
) {
/**
* Cover URL the UI should render, or null when the artist has no
* representative cover. Prefers the server-provided `coverUrl`
* (relative, e.g. `/api/artists` ships it); otherwise derives the
* first cached album's `/api/albums/{id}/cover` via [coverAlbumId].
*
* The fallback matters because cached_artists stores no cover and
* the sync payload carries none — so for cache-sourced artist tiles
* we mirror Flutter's `artistTileProvider`, which JOINs the first
* album to supply the cover. Resolve through `ServerImage`.
*/
val displayCoverUrl: String?
get() = when {
coverUrl.isNotEmpty() -> coverUrl
coverAlbumId.isNotEmpty() -> "/api/albums/$coverAlbumId/cover"
else -> null
}
}
@@ -58,6 +58,9 @@ class LibraryRepositoryTest {
CachedArtistEntity(id = "a1", name = "Boards of Canada", sortName = "Boards of Canada"), CachedArtistEntity(id = "a1", name = "Boards of Canada", sortName = "Boards of Canada"),
), ),
) )
// observeArtists now combines with albums (to derive the tile
// cover from the first album); stub it so combine can emit.
every { albumDao.observeAll() } returns flowOf(emptyList())
val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit) val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit)
repo.observeArtists().test { repo.observeArtists().test {