chore: stage previously uncommitted screen changes (ArtistDetail, ItunesArtist, NowPlaying)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-18 20:52:42 +02:00
1 parent 85252e24df
commit 925c173c15
3 files changed
+258 -15

No files matched your search

@@ -10,16 +10,23 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -61,6 +68,13 @@ fun ArtistDetailScreen(
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
var showAllAlbums by remember { mutableStateOf(false) }
var allAlbumsTitle by remember { mutableStateOf("") }
var allAlbumEntries by remember { mutableStateOf<List<AllAlbumEntry>>(emptyList()) }
BackHandler(enabled = showAllAlbums && state.partialAlbum == null && state.subAlbumItem == null) {
showAllAlbums = false
}
LaunchedEffect(state.pendingShufflePlay) {
state.pendingShufflePlay?.let { songs ->
onPlay(songs, 0)
@@ -149,6 +163,15 @@ fun ArtistDetailScreen(
// Album categories (computed once iTunes data arrives)
val cats = if (state.iAlbums.isNotEmpty()) categorizeAlbums(artist.album, state.iAlbums) else null
if (showAllAlbums) {
AllAlbumsOverlay(
title = allAlbumsTitle,
entries = allAlbumEntries,
onBack = { showAllAlbums = false },
)
return
}
LazyColumn(Modifier.fillMaxSize()) {
// Back link
item {
@@ -280,7 +303,7 @@ fun ArtistDetailScreen(
// Navidrome top songs
if (state.topSongs.isNotEmpty()) {
item { SectionLabel("Top Songs") }
itemsIndexed(state.topSongs, key = { _, s -> "top_${s.id}" }) { idx, song ->
itemsIndexed(state.topSongs, key = { idx, s -> "top_${idx}_${s.id}" }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
@@ -297,7 +320,7 @@ fun ArtistDetailScreen(
// ── iTunes: Weltweit populär ──────────────────────────────────────────
if (state.iTracks.isNotEmpty()) {
item { ArtistSectionHeader("Weltweit populär") }
itemsIndexed(state.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
itemsIndexed(state.iTracks, key = { idx, t -> "itrack_${idx}_${t.trackId ?: 0L}" }) { _, track ->
ItunesArtistTrackRow(
track = track,
isPreviewing = state.previewingId == track.trackId,
@@ -324,7 +347,17 @@ fun ArtistDetailScreen(
// ── Album sections (categorized once iTunes data is ready) ────────────
if (cats != null) {
if (cats.downloaded.isNotEmpty()) {
item { ArtistSectionHeader("In Library", cats.downloaded.size) }
item {
ArtistSectionHeader("In Library", cats.downloaded.size,
onShowAll = if (cats.downloaded.size > 4) ({
allAlbumsTitle = "In Library"
allAlbumEntries = cats.downloaded.map { (nd, _) ->
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
}
showAllAlbums = true
}) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
@@ -344,7 +377,24 @@ fun ArtistDetailScreen(
}
if (cats.partial.isNotEmpty()) {
item { ArtistSectionHeader("Unvollständig", cats.partial.size) }
item {
ArtistSectionHeader("Unvollständig", cats.partial.size,
onShowAll = if (cats.partial.size > 4) ({
allAlbumsTitle = "Unvollständig"
allAlbumEntries = cats.partial.map { (nd, ia, counts) ->
AllAlbumEntry(
name = nd.name,
year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { vm.loadPartialAlbum(nd, ia) },
onDownload = { vm.showDownloadSheet(ia) },
)
}
showAllAlbums = true
}) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
@@ -367,7 +417,24 @@ fun ArtistDetailScreen(
}
if (cats.notDownloaded.isNotEmpty()) {
item { ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size) }
item {
ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size,
onShowAll = if (cats.notDownloaded.size > 4) ({
allAlbumsTitle = "Nicht heruntergeladen"
allAlbumEntries = cats.notDownloaded.map { ia ->
AllAlbumEntry(
name = ia.collectionName ?: "—",
year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
dimmed = true,
onClick = { vm.loadSubAlbum(ia) },
onDownload = { vm.showDownloadSheet(ia) },
)
}
showAllAlbums = true
}) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
@@ -460,3 +527,102 @@ private fun SectionLabel(title: String) {
modifier = Modifier.padding(start = 20.dp, top = 4.dp, bottom = 12.dp),
)
}
internal data class AllAlbumEntry(
val name: String,
val year: String?,
val artUrl: String?,
val badge: String? = null,
val dimmed: Boolean = false,
val onClick: () -> Unit,
val onDownload: (() -> Unit)? = null,
)
@Composable
internal fun AllAlbumsOverlay(
title: String,
entries: List<AllAlbumEntry>,
onBack: () -> Unit,
) {
var query by remember { mutableStateOf("") }
val filtered = remember(query, entries) {
if (query.isBlank()) entries
else entries.filter { it.name.contains(query, ignoreCase = true) }
}
Column(
Modifier
.fillMaxSize()
.background(NsBackground),
) {
// Top bar
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 12.dp, top = 12.dp, bottom = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", tint = NsOnSurface2)
}
Text(
title,
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 18.sp, color = NsOnSurface,
modifier = Modifier.weight(1f),
)
Text(
"${filtered.size}",
fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2.copy(0.6f),
)
}
// Search bar
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Suchen…", color = NsOnSurface2) },
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
trailingIcon = if (query.isNotEmpty()) { {
IconButton(onClick = { query = "" }) {
Icon(Icons.Default.Clear, null, tint = NsOnSurface2)
}
} } else null,
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surface,
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
)
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize(),
) {
items(filtered.size, key = { idx -> "${idx}_${filtered[idx].name}${filtered[idx].year ?: ""}" }) { idx ->
val entry = filtered[idx]
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
AlbumThumb(
name = entry.name,
year = entry.year,
artUrl = entry.artUrl,
badge = entry.badge,
dimmed = entry.dimmed,
onClick = entry.onClick,
onDownload = entry.onDownload,
)
}
}
}
}
}
@@ -7,13 +7,18 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
@@ -97,6 +102,19 @@ fun ItunesArtistScreen(
categorizeAlbums(artistState.ndAlbums, artistState.iAlbums)
else null
var showAllAlbums by remember { mutableStateOf(false) }
var allAlbumsTitle by remember { mutableStateOf("") }
var allAlbumEntries by remember { mutableStateOf<List<AllAlbumEntry>>(emptyList()) }
if (showAllAlbums) {
AllAlbumsOverlay(
title = allAlbumsTitle,
entries = allAlbumEntries,
onBack = { showAllAlbums = false },
)
return
}
// Pick backdrop: first Navidrome album cover, else first iTunes album art
val backdropUrl = artistState.ndAlbums.firstOrNull()?.let { coverUrl(it.coverArt) }
?: artistState.iAlbums.firstOrNull()?.artworkUrl100?.replace("100x100bb", "300x300bb")
@@ -204,7 +222,17 @@ fun ItunesArtistScreen(
// ── Downloaded albums ─────────────────────────────────────────────
cats?.let { c ->
if (c.downloaded.isNotEmpty()) {
item { ArtistSectionHeader("In Library", c.downloaded.size) }
item {
ArtistSectionHeader("In Library", c.downloaded.size,
onShowAll = if (c.downloaded.size > 4) ({
allAlbumsTitle = "In Library"
allAlbumEntries = c.downloaded.map { (nd, _) ->
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
}
showAllAlbums = true
}) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
@@ -225,7 +253,24 @@ fun ItunesArtistScreen(
// ── Partial albums ────────────────────────────────────────────
if (c.partial.isNotEmpty()) {
item { ArtistSectionHeader("Unvollständig", c.partial.size) }
item {
ArtistSectionHeader("Unvollständig", c.partial.size,
onShowAll = if (c.partial.size > 4) ({
allAlbumsTitle = "Unvollständig"
allAlbumEntries = c.partial.map { (nd, ia, counts) ->
AllAlbumEntry(
name = nd.name,
year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { onPartialAlbumOpen(nd, ia) },
onDownload = { onDownload(ia) },
)
}
showAllAlbums = true
}) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
@@ -248,7 +293,24 @@ fun ItunesArtistScreen(
// ── Not downloaded ────────────────────────────────────────────
if (c.notDownloaded.isNotEmpty()) {
item { ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size) }
item {
ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size,
onShowAll = if (c.notDownloaded.size > 4) ({
allAlbumsTitle = "Nicht heruntergeladen"
allAlbumEntries = c.notDownloaded.map { ia ->
AllAlbumEntry(
name = ia.collectionName ?: "—",
year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
dimmed = true,
onClick = { onItunesAlbumOpen(ia) },
onDownload = { onDownload(ia) },
)
}
showAllAlbums = true
}) else null
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
@@ -384,7 +446,7 @@ internal fun ItunesArtistTrackRow(
}
@Composable
internal fun ArtistSectionHeader(title: String, count: Int? = null) {
internal fun ArtistSectionHeader(title: String, count: Int? = null, onShowAll: (() -> Unit)? = null) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 10.dp, end = 16.dp),
@@ -399,5 +461,13 @@ internal fun ArtistSectionHeader(title: String, count: Int? = null) {
.background(NsSurface2)
.padding(horizontal = 7.dp, vertical = 2.dp))
}
if (onShowAll != null) {
Spacer(Modifier.width(10.dp))
Text(
"Alle anzeigen",
fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
modifier = Modifier.clickable(onClick = onShowAll),
)
}
}
}
@@ -75,6 +75,12 @@ fun NowPlayingScreen(
val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) }
// Preload lyrics as soon as NowPlaying opens — so the Lyrics tab shows instantly
val lyricsVm: LyricsViewModel = viewModel()
LaunchedEffect(song?.id) {
if (song != null) lyricsVm.loadForSong(song)
}
val statusBarTop: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getTop(this).toDp() }
val topPad = maxOf(statusBarTop + 16.dp, 52.dp)
val navBarBottom: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getBottom(this).toDp() }
@@ -392,7 +398,6 @@ fun NowPlayingScreen(
// ── LYRICS TAB ─────────────────────────────────────────────────
if (tab == NpTab.LYRICS && song != null) {
val lyricsVm: LyricsViewModel = viewModel()
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
val lyricsScrollState = rememberScrollState()
val density = LocalDensity.current
@@ -404,19 +409,21 @@ fun NowPlayingScreen(
val lyricsPaddingPx = remember(density) { with(density) { 200.dp.toPx() } }
var lyricsViewH by remember { mutableIntStateOf(0) }
LaunchedEffect(song.id) { lyricsVm.loadForSong(song) }
// 200ms lookahead — same as web app for better sync feel
val activeIdx = if (lyricsState.lines.isNotEmpty())
lyricsState.lines.indexOfLast { it.timeMs <= state.positionMs + 200L }.coerceAtLeast(0)
else -1
// Center the active line when it changes — matches web behavior (no drift)
// Center the active line when it changes
LaunchedEffect(activeIdx) {
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
val target = (lyricsPaddingPx + activeIdx * lineHeightPx + lineHeightPx / 2f
- lyricsViewH * 0.5f).coerceAtLeast(0f).toInt()
lyricsScrollState.animateScrollTo(target, tween(350, easing = FastOutSlowInEasing))
// Spring handles rapid line changes gracefully — starts from current scroll velocity
lyricsScrollState.animateScrollTo(
target,
spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow),
)
}
when {