diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt index 15d004e..50540af 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt @@ -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>(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, + 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, + ) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ItunesArtistScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ItunesArtistScreen.kt index aeaf5f2..eab8bd8 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ItunesArtistScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ItunesArtistScreen.kt @@ -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>(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), + ) + } } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt index 9b3a24c..30632b9 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt @@ -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 {