chore: stage previously uncommitted screen changes (ArtistDetail, ItunesArtist, NowPlaying)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user