From 1aa218f3e32ac2f3202fcbd775f923fdfa4d5e82 Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 22 Sep 2026 20:13:10 +0200 Subject: [PATCH] feat(android): Rollbalken zum Ziehen in den langen Listen Co-Authored-By: Claude Opus 5 --- android/app/build.gradle.kts | 4 +- .../novasonic/app/ui/component/Rollbalken.kt | 112 +++++ .../novasonic/app/ui/screen/AlbumScreen.kt | 328 +++++++-------- .../app/ui/screen/GenreSongsScreen.kt | 34 +- .../app/ui/screen/OfflineDetailScreen.kt | 240 +++++------ .../app/ui/screen/PlaylistDetailScreen.kt | 382 +++++++++--------- .../novasonic/app/ui/screen/StarredScreen.kt | 98 +++-- 7 files changed, 681 insertions(+), 517 deletions(-) create mode 100644 android/app/src/main/kotlin/com/novasonic/app/ui/component/Rollbalken.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fdd6035..ea6dd32 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.novasonic.app" minSdk = 26 targetSdk = 35 - versionCode = 18 - versionName = "1.15" + versionCode = 19 + versionName = "1.16" // Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in // gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der // Platzhalter stehen und der App-Link greift schlicht nicht. diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/Rollbalken.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/Rollbalken.kt new file mode 100644 index 0000000..bab1699 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/Rollbalken.kt @@ -0,0 +1,112 @@ +package com.novasonic.app.ui.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +/** + * Rollbalken zum Ziehen fuer eine [LazyListState] — das Gegenstueck zum verbreiterten Balken im Web. + * + * Compose bringt keinen mit: eine LazyColumn zeigt gar nichts an, egal wie lang sie ist. Bei einer + * Playlist mit 1900 Titeln bleibt so nur Wischen, bis der Daumen glueht. + * + * Gerechnet wird in ITEMS, nicht in Pixeln: die tatsaechliche Gesamthoehe einer LazyColumn ist + * unbekannt, solange nicht alles gebaut wurde, und bei unterschiedlich hohen Zeilen laesst sie sich + * auch nicht ausrechnen. Ueber den Anteil sichtbarer an allen Eintraegen stimmt der Balken bei + * gleich hohen Zeilen — und genau die haben Song- und Albumlisten. + * + * Sichtbar bleibt er dauerhaft, nur gedaempft. Ein Balken, der erst beim Scrollen erscheint, laesst + * sich nicht greifen, und Greifen ist hier der ganze Zweck. + */ +@Composable +fun Rollbalken( + state: LazyListState, + modifier: Modifier = Modifier, + breite: Dp = 8.dp, + minHoehe: Dp = 44.dp, + farbe: Color = Color.White, +) { + val dichte = LocalDensity.current + val scope = rememberCoroutineScope() + var spurPx by remember { mutableFloatStateOf(0f) } + var ziehen by remember { mutableStateOf(false) } + // Beim Ziehen laeuft die Position als Kommazahl mit. Wuerde jeder Zeiger-Schritt aus + // firstVisibleItemIndex neu rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal + // verloren — der Balken bliebe bei langsamem Ziehen stehen. + var zielPos by remember { mutableFloatStateOf(0f) } + + val info = state.layoutInfo + val gesamt = info.totalItemsCount + val zuSehen = info.visibleItemsInfo.size + val scrollbar = gesamt > 0 && zuSehen > 0 && gesamt > zuSehen + + val alpha by animateFloatAsState( + targetValue = if (state.isScrollInProgress || ziehen) 0.85f else 0.28f, + animationSpec = tween(if (state.isScrollInProgress || ziehen) 120 else 450), + label = "rollbalken-alpha", + ) + + if (!scrollbar) return + + val maxIndex = (gesamt - zuSehen).coerceAtLeast(1) + val minHoehePx = with(dichte) { minHoehe.toPx() } + val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx) + val frei = (spurPx - daumenPx).coerceAtLeast(0f) + // Waehrend des Ziehens fuehrt die Kommaposition, sonst der tatsaechliche Listenstand. + val anteil = if (ziehen) zielPos / maxIndex + else state.firstVisibleItemIndex.toFloat() / maxIndex + val daumenY = (frei * anteil.coerceIn(0f, 1f)) + + Box( + modifier + .width(breite) + .fillMaxHeight() + .onSizeChanged { spurPx = it.height.toFloat() } + .pointerInput(gesamt, zuSehen, frei) { + detectVerticalDragGestures( + onDragStart = { + ziehen = true + zielPos = state.firstVisibleItemIndex.toFloat() + }, + onDragEnd = { ziehen = false }, + onDragCancel = { ziehen = false }, + ) { change, delta -> + change.consume() + if (frei <= 0f) return@detectVerticalDragGestures + zielPos = (zielPos + delta * maxIndex / frei).coerceIn(0f, maxIndex.toFloat()) + scope.launch { state.scrollToItem(zielPos.roundToInt()) } + } + } + ) { + Box( + Modifier + .offset { IntOffset(0, daumenY.roundToInt()) } + .width(breite) + .height(with(dichte) { daumenPx.toDp() }) + .padding(horizontal = 2.dp) + .clip(RoundedCornerShape(50)) + .background(farbe.copy(alpha = alpha)) + ) + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt index f2be1f7..a5f4c12 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AlbumScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -37,6 +38,7 @@ import com.novasonic.app.player.PlayerState import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.theme.* import com.novasonic.app.viewmodel.AlbumViewModel +import com.novasonic.app.ui.component.Rollbalken @Composable fun AlbumScreen( @@ -75,169 +77,177 @@ fun AlbumScreen( return } - LazyColumn(Modifier.fillMaxSize()) { - item { - // ── Hero ───────────────────────────────────────────────────────── - Box(Modifier.fillMaxWidth()) { - AsyncImage( - model = coverUrl(album.coverArt), - contentDescription = album.name, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .background(NsSurface2), - ) - // gradient overlay - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .background( - Brush.verticalGradient( - 0.0f to Color.Transparent, - 0.40f to NsBackground.copy(alpha = 0.35f), - 1.0f to NsBackground, + val listState = rememberLazyListState() + Box(Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize(), state = listState) { + item { + // ── Hero ───────────────────────────────────────────────────────── + Box(Modifier.fillMaxWidth()) { + AsyncImage( + model = coverUrl(album.coverArt), + contentDescription = album.name, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background(NsSurface2), + ) + // gradient overlay + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background( + Brush.verticalGradient( + 0.0f to Color.Transparent, + 0.40f to NsBackground.copy(alpha = 0.35f), + 1.0f to NsBackground, + ) ) + ) + // back button — frosted circle + Box( + Modifier + .statusBarsPadding() + .padding(start = 14.dp, top = 14.dp) + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .clickable(onClick = onBack) + .align(Alignment.TopStart), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, "Zurück", + tint = Color.White, modifier = Modifier.size(18.dp), ) + } + // album info overlaid at bottom + Column( + Modifier + .align(Alignment.BottomStart) + .padding(horizontal = 20.dp, vertical = 20.dp), + ) { + Text( + album.name, + fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, + fontSize = 26.sp, color = Color.White, + maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + if (!album.artist.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + album.artist!!, fontFamily = Outfit, + fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent, + modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank()) + Modifier.clickable { onArtistClick(album.artistId!!) } + else Modifier, + ) + } + val meta = listOfNotNull( + album.year?.toString(), + album.genre?.takeIf { it.isNotBlank() }, + if (album.songCount > 0) "${album.songCount} Songs" else null, + ).joinToString(" · ") + if (meta.isNotBlank()) { + Spacer(Modifier.height(3.dp)) + Text( + meta, fontFamily = Outfit, fontSize = 12.sp, + color = Color.White.copy(alpha = 0.55f), + ) + } + } + } + + // ── Play / Shuffle / Merken / Download ──────────────────────────── + // Gleiche Leiste wie beim Playlist-Detail: vier gleich breite, reine Symbolknoepfe. + // Play und Shuffle trugen hier noch Beschriftungen, waehrend Herz und Download fest + // 46 dp breit waren — die Reihe war dadurch ungleich geteilt und die Schrift wurde auf + // schmalen Geraeten abgeschnitten. Play bleibt gefuellt als Hauptaktion; die uebrigen + // sind umrandet und faerben sich erst accent, wenn sie aktiv sind. + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Button( + onClick = { onPlay(album.song, 0) }, + modifier = Modifier.weight(1f).height(46.dp), + colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(0.dp), + ) { + Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp)) + } + OutlinedButton( + onClick = { onPlay(album.song.shuffled(), 0) }, + modifier = Modifier.weight(1f).height(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(0.dp), + ) { + Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent) + } + // Album merken — derselbe Weg wie beim Song: Navidrome behandelt Song-, Album- und + // Kuenstler-IDs beim Markieren gleich. + val overrides by StarState.overrides.collectAsStateWithLifecycle() + val gemerkt = overrides[album.id] ?: (album.starred != null) + OutlinedButton( + onClick = { vm.merkenUmschalten(album.id, album.starred != null) }, + modifier = Modifier.weight(1f).height(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( + brush = SolidColor(if (gemerkt) NsAccent else NsOnSurface3) + ), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(0.dp), + ) { + Icon( + if (gemerkt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, + if (gemerkt) "Gemerkt" else "Album merken", + Modifier.size(20.dp), tint = if (gemerkt) NsAccent else NsOnSurface2, + ) + } + val isFullyDownloaded = downloadGroup != null && + downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks && + downloadGroup!!.totalTracks > 0 + OutlinedButton( + // Tappable unless fully done — resumes / re-downloads any missing songs. + onClick = { if (!isFullyDownloaded) vm.downloadAlbum() }, + modifier = Modifier.weight(1f).height(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( + brush = SolidColor(if (isFullyDownloaded) NsAccent else NsOnSurface3) + ), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(0.dp), + ) { + when { + isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", Modifier.size(20.dp), tint = NsAccent) + isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp) + else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsOnSurface2) + } + } + } + + HorizontalDivider(color = NsBorder) + } + + itemsIndexed(album.song, key = { _, s -> s.id }) { index, song -> + SongRow( + song = song, + coverUrl = null, + isPlaying = playerState.currentSong?.id == song.id, + trackNumber = song.track ?: (index + 1), + starred = song.starred != null, + onPlay = { onPlay(album.song, index) }, + onStarToggle = onStarToggle?.let { cb -> { cb(song) } }, + onMore = onShowContextMenu?.let { cb -> { cb(song) } }, ) - // back button — frosted circle - Box( - Modifier - .statusBarsPadding() - .padding(start = 14.dp, top = 14.dp) - .size(36.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.45f)) - .clickable(onClick = onBack) - .align(Alignment.TopStart), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, "Zurück", - tint = Color.White, modifier = Modifier.size(18.dp), - ) - } - // album info overlaid at bottom - Column( - Modifier - .align(Alignment.BottomStart) - .padding(horizontal = 20.dp, vertical = 20.dp), - ) { - Text( - album.name, - fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, - fontSize = 26.sp, color = Color.White, - maxLines = 2, overflow = TextOverflow.Ellipsis, - ) - if (!album.artist.isNullOrBlank()) { - Spacer(Modifier.height(4.dp)) - Text( - album.artist!!, fontFamily = Outfit, - fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent, - modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank()) - Modifier.clickable { onArtistClick(album.artistId!!) } - else Modifier, - ) - } - val meta = listOfNotNull( - album.year?.toString(), - album.genre?.takeIf { it.isNotBlank() }, - if (album.songCount > 0) "${album.songCount} Songs" else null, - ).joinToString(" · ") - if (meta.isNotBlank()) { - Spacer(Modifier.height(3.dp)) - Text( - meta, fontFamily = Outfit, fontSize = 12.sp, - color = Color.White.copy(alpha = 0.55f), - ) - } - } } - - // ── Play / Shuffle / Merken / Download ──────────────────────────── - // Gleiche Leiste wie beim Playlist-Detail: vier gleich breite, reine Symbolknoepfe. - // Play und Shuffle trugen hier noch Beschriftungen, waehrend Herz und Download fest - // 46 dp breit waren — die Reihe war dadurch ungleich geteilt und die Schrift wurde auf - // schmalen Geraeten abgeschnitten. Play bleibt gefuellt als Hauptaktion; die uebrigen - // sind umrandet und faerben sich erst accent, wenn sie aktiv sind. - Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Button( - onClick = { onPlay(album.song, 0) }, - modifier = Modifier.weight(1f).height(46.dp), - colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White), - shape = RoundedCornerShape(14.dp), - contentPadding = PaddingValues(0.dp), - ) { - Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp)) - } - OutlinedButton( - onClick = { onPlay(album.song.shuffled(), 0) }, - modifier = Modifier.weight(1f).height(46.dp), - border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))), - shape = RoundedCornerShape(14.dp), - contentPadding = PaddingValues(0.dp), - ) { - Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent) - } - // Album merken — derselbe Weg wie beim Song: Navidrome behandelt Song-, Album- und - // Kuenstler-IDs beim Markieren gleich. - val overrides by StarState.overrides.collectAsStateWithLifecycle() - val gemerkt = overrides[album.id] ?: (album.starred != null) - OutlinedButton( - onClick = { vm.merkenUmschalten(album.id, album.starred != null) }, - modifier = Modifier.weight(1f).height(46.dp), - border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( - brush = SolidColor(if (gemerkt) NsAccent else NsOnSurface3) - ), - shape = RoundedCornerShape(14.dp), - contentPadding = PaddingValues(0.dp), - ) { - Icon( - if (gemerkt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, - if (gemerkt) "Gemerkt" else "Album merken", - Modifier.size(20.dp), tint = if (gemerkt) NsAccent else NsOnSurface2, - ) - } - val isFullyDownloaded = downloadGroup != null && - downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks && - downloadGroup!!.totalTracks > 0 - OutlinedButton( - // Tappable unless fully done — resumes / re-downloads any missing songs. - onClick = { if (!isFullyDownloaded) vm.downloadAlbum() }, - modifier = Modifier.weight(1f).height(46.dp), - border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( - brush = SolidColor(if (isFullyDownloaded) NsAccent else NsOnSurface3) - ), - shape = RoundedCornerShape(14.dp), - contentPadding = PaddingValues(0.dp), - ) { - when { - isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", Modifier.size(20.dp), tint = NsAccent) - isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp) - else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsOnSurface2) - } - } - } - - HorizontalDivider(color = NsBorder) + item { Spacer(Modifier.height(16.dp)) } } - - itemsIndexed(album.song, key = { _, s -> s.id }) { index, song -> - SongRow( - song = song, - coverUrl = null, - isPlaying = playerState.currentSong?.id == song.id, - trackNumber = song.track ?: (index + 1), - starred = song.starred != null, - onPlay = { onPlay(album.song, index) }, - onStarToggle = onStarToggle?.let { cb -> { cb(song) } }, - onMore = onShowContextMenu?.let { cb -> { cb(song) } }, - ) - } - item { Spacer(Modifier.height(16.dp)) } + Rollbalken( + state = listState, + modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp), + farbe = NsOnSurface2, + ) } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/GenreSongsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/GenreSongsScreen.kt index 6771677..bc1f823 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/GenreSongsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/GenreSongsScreen.kt @@ -2,6 +2,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -18,6 +19,7 @@ import com.novasonic.app.app import com.novasonic.app.player.PlayerState import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.theme.* +import com.novasonic.app.ui.component.Rollbalken @Composable fun GenreSongsScreen( @@ -77,19 +79,29 @@ fun GenreSongsScreen( songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { Text("Keine Songs gefunden", color = NsOnSurface2) } - else -> LazyColumn { - itemsIndexed(songs, key = { _, s -> s.id }) { idx, song -> - SongRow( - song = song, - coverUrl = coverUrl(song.coverArt), - isPlaying = playerState.currentSong?.id == song.id, - starred = song.starred != null, - onPlay = { onPlay(songs, idx) }, - onStarToggle = { onStarToggle(song) }, - onMore = onShowContextMenu?.let { cb -> { cb(song) } }, + else -> { + val listState = rememberLazyListState() + Box(Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize(), state = listState) { + itemsIndexed(songs, key = { _, s -> s.id }) { idx, song -> + SongRow( + song = song, + coverUrl = coverUrl(song.coverArt), + isPlaying = playerState.currentSong?.id == song.id, + starred = song.starred != null, + onPlay = { onPlay(songs, idx) }, + onStarToggle = { onStarToggle(song) }, + onMore = onShowContextMenu?.let { cb -> { cb(song) } }, + ) + } + item { Spacer(Modifier.height(8.dp)) } + } + Rollbalken( + state = listState, + modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp), + farbe = NsOnSurface2, ) } - item { Spacer(Modifier.height(8.dp)) } } } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/OfflineDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/OfflineDetailScreen.kt index 57bd7fb..d9bd410 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/OfflineDetailScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/OfflineDetailScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -37,6 +38,7 @@ import com.novasonic.app.player.PlayerState import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.theme.* import com.novasonic.app.viewmodel.OfflineDetailViewModel +import com.novasonic.app.ui.component.Rollbalken @Composable fun OfflineDetailScreen( @@ -78,131 +80,139 @@ fun OfflineDetailScreen( onPlay(liste, i) } - LazyColumn(Modifier.fillMaxSize()) { - item { - // ── Hero ───────────────────────────────────────────────────────── - Box(Modifier.fillMaxWidth()) { - AsyncImage( - model = coverUrl(g.coverArt), - contentDescription = g.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2), - ) - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .background( - Brush.verticalGradient( - 0.0f to Color.Transparent, - 0.40f to NsBackground.copy(alpha = 0.35f), - 1.0f to NsBackground, + val listState = rememberLazyListState() + Box(Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize(), state = listState) { + item { + // ── Hero ───────────────────────────────────────────────────────── + Box(Modifier.fillMaxWidth()) { + AsyncImage( + model = coverUrl(g.coverArt), + contentDescription = g.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2), + ) + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background( + Brush.verticalGradient( + 0.0f to Color.Transparent, + 0.40f to NsBackground.copy(alpha = 0.35f), + 1.0f to NsBackground, + ) ) + ) + Box( + Modifier + .statusBarsPadding() + .padding(start = 14.dp, top = 14.dp) + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .clickable(onClick = onBack) + .align(Alignment.TopStart), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", + tint = Color.White, modifier = Modifier.size(18.dp)) + } + Column( + Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp), + ) { + Text( + g.name, + fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, + fontSize = 26.sp, color = Color.White, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) - ) - Box( - Modifier - .statusBarsPadding() - .padding(start = 14.dp, top = 14.dp) - .size(36.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.45f)) - .clickable(onClick = onBack) - .align(Alignment.TopStart), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück", - tint = Color.White, modifier = Modifier.size(18.dp)) + Spacer(Modifier.height(4.dp)) + Text( + "Offline · ${tracks.size} Titel", + fontFamily = Outfit, fontSize = 13.sp, + color = Color.White.copy(alpha = 0.65f), + ) + } } - Column( - Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp), + + // ── Play / Shuffle / Edit row ───────────────────────────────────── + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Text( - g.name, - fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, - fontSize = 26.sp, color = Color.White, - maxLines = 2, overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(4.dp)) - Text( - "Offline · ${tracks.size} Titel", - fontFamily = Outfit, fontSize = 13.sp, - color = Color.White.copy(alpha = 0.65f), - ) + Button( + onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, 0) }, + modifier = Modifier.weight(1f).height(46.dp), + colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White), + shape = RoundedCornerShape(14.dp), + ) { + Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(6.dp)) + Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp) + } + OutlinedButton( + onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar.shuffled(), 0) }, + modifier = Modifier.weight(1f).height(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))), + shape = RoundedCornerShape(14.dp), + ) { + Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent) + Spacer(Modifier.width(6.dp)) + Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent) + } + OutlinedButton( + onClick = { editMode = !editMode }, + modifier = Modifier.height(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( + brush = SolidColor(if (editMode) NsAccent else NsOnSurface3) + ), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(horizontal = 14.dp), + ) { + Icon( + if (editMode) Icons.Default.Done else Icons.Default.Edit, null, + modifier = Modifier.size(18.dp), + tint = if (editMode) NsAccent else NsOnSurface2, + ) + } } + + HorizontalDivider(color = NsBorder) } - // ── Play / Shuffle / Edit row ───────────────────────────────────── - Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Button( - onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, 0) }, - modifier = Modifier.weight(1f).height(46.dp), - colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White), - shape = RoundedCornerShape(14.dp), - ) { - Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(6.dp)) - Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp) - } - OutlinedButton( - onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar.shuffled(), 0) }, - modifier = Modifier.weight(1f).height(46.dp), - border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))), - shape = RoundedCornerShape(14.dp), - ) { - Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent) - Spacer(Modifier.width(6.dp)) - Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent) - } - OutlinedButton( - onClick = { editMode = !editMode }, - modifier = Modifier.height(46.dp), - border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( - brush = SolidColor(if (editMode) NsAccent else NsOnSurface3) - ), - shape = RoundedCornerShape(14.dp), - contentPadding = PaddingValues(horizontal = 14.dp), - ) { - Icon( - if (editMode) Icons.Default.Done else Icons.Default.Edit, null, - modifier = Modifier.size(18.dp), - tint = if (editMode) NsAccent else NsOnSurface2, - ) - } - } - - HorizontalDivider(color = NsBorder) - } - - itemsIndexed(songs, key = { _, s -> s.id }) { index, song -> - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.weight(1f)) { - SongRow( - song = song, - coverUrl = coverUrl(song.coverArt), - isPlaying = playerState.currentSong?.id == song.id, - // Position in der abspielbaren Liste suchen — die Anzeige enthält auch - // Titel, die noch fehlen, die Indizes decken sich also nicht. - onPlay = { - val i = spielbar.indexOfFirst { it.id == song.id } - if (i >= 0) onPlay(spielbar, i) - else android.widget.Toast.makeText( - ctx, "Dieser Titel ist noch nicht heruntergeladen", - android.widget.Toast.LENGTH_SHORT).show() - }, - ) - } - if (editMode) { - IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) { - Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen", - tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp)) + itemsIndexed(songs, key = { _, s -> s.id }) { index, song -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { + SongRow( + song = song, + coverUrl = coverUrl(song.coverArt), + isPlaying = playerState.currentSong?.id == song.id, + // Position in der abspielbaren Liste suchen — die Anzeige enthält auch + // Titel, die noch fehlen, die Indizes decken sich also nicht. + onPlay = { + val i = spielbar.indexOfFirst { it.id == song.id } + if (i >= 0) onPlay(spielbar, i) + else android.widget.Toast.makeText( + ctx, "Dieser Titel ist noch nicht heruntergeladen", + android.widget.Toast.LENGTH_SHORT).show() + }, + ) + } + if (editMode) { + IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) { + Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen", + tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp)) + } } } } + item { Spacer(Modifier.height(16.dp)) } } - item { Spacer(Modifier.height(16.dp)) } + Rollbalken( + state = listState, + modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp), + farbe = NsOnSurface2, + ) } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt index 105cad7..ae82dbb 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -39,6 +40,7 @@ import com.novasonic.app.player.PlayerState import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.theme.* import com.novasonic.app.viewmodel.PlaylistViewModel +import com.novasonic.app.ui.component.Rollbalken @Composable fun PlaylistDetailScreen( @@ -91,207 +93,215 @@ fun PlaylistDetailScreen( return } - LazyColumn(Modifier.fillMaxSize()) { - item { - // ── Hero ───────────────────────────────────────────────────────── - Box(Modifier.fillMaxWidth()) { - AsyncImage( - model = coverUrl(pl.coverArt), - contentDescription = pl.name, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .background(NsSurface2), - ) - Box( - Modifier - .fillMaxWidth() - .aspectRatio(1f) - .background( - Brush.verticalGradient( - 0.0f to Color.Transparent, - 0.40f to NsBackground.copy(alpha = 0.35f), - 1.0f to NsBackground, + val listState = rememberLazyListState() + Box(Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize(), state = listState) { + item { + // ── Hero ───────────────────────────────────────────────────────── + Box(Modifier.fillMaxWidth()) { + AsyncImage( + model = coverUrl(pl.coverArt), + contentDescription = pl.name, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background(NsSurface2), + ) + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background( + Brush.verticalGradient( + 0.0f to Color.Transparent, + 0.40f to NsBackground.copy(alpha = 0.35f), + 1.0f to NsBackground, + ) ) - ) - ) - Box( - Modifier - .statusBarsPadding() - .padding(start = 14.dp, top = 14.dp) - .size(36.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.45f)) - .clickable(onClick = onBack) - .align(Alignment.TopStart), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, "Zurück", - tint = Color.White, modifier = Modifier.size(18.dp), ) - } - // Edit + Delete buttons (top-right) - Row( - Modifier - .statusBarsPadding() - .padding(end = 14.dp, top = 14.dp) - .align(Alignment.TopEnd), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { Box( Modifier + .statusBarsPadding() + .padding(start = 14.dp, top = 14.dp) .size(36.dp) .clip(CircleShape) .background(Color.Black.copy(alpha = 0.45f)) - .clickable { showRenameDialog = true }, + .clickable(onClick = onBack) + .align(Alignment.TopStart), contentAlignment = Alignment.Center, - ) { - Icon(Icons.Default.Edit, "Bearbeiten", - tint = Color.White, modifier = Modifier.size(18.dp)) - } - Box( - Modifier - .size(36.dp) - .clip(CircleShape) - .background(Color.Black.copy(alpha = 0.45f)) - .clickable { showDeleteDialog = true }, - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Default.Delete, "Löschen", - tint = Color.White, modifier = Modifier.size(18.dp)) - } - } - Column( - Modifier - .align(Alignment.BottomStart) - .padding(horizontal = 20.dp, vertical = 20.dp), - ) { - Text( - pl.name, - fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, - fontSize = 26.sp, color = Color.White, - maxLines = 2, overflow = TextOverflow.Ellipsis, - ) - // Der Besitzer stand hier bisher bei JEDER Liste, auch bei den eigenen, und ohne - // Beschriftung — bei einer geteilten Liste ist genau das die interessante Angabe. - val meta = listOfNotNull( - if (pl.songCount > 0) "${pl.songCount} Songs" else null, - pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }?.let { "von $it" }, - ).joinToString(" · ") - if (meta.isNotBlank()) { - Spacer(Modifier.height(4.dp)) - Text( - meta, fontFamily = Outfit, fontSize = 13.sp, - color = Color.White.copy(alpha = 0.65f), - ) - } - if (!pl.comment.isNullOrBlank()) { - Spacer(Modifier.height(3.dp)) - Text( - pl.comment!!, fontFamily = Outfit, fontSize = 12.sp, - color = Color.White.copy(alpha = 0.45f), - maxLines = 2, overflow = TextOverflow.Ellipsis, - ) - } - } - } - - // ── Play / Shuffle / Edit / Download ────────────────────────────── - // Four buttons in one row left no room for labels (the Play/Shuffle text was being - // clipped away), so all four are equal-width and icon-only. Play stays filled as the - // primary action; the rest are outlined and tint accent when active/done. - Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Button( - onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) }, - modifier = Modifier.weight(1f).height(46.dp), - colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White), - shape = RoundedCornerShape(14.dp), - contentPadding = PaddingValues(0.dp), - ) { - Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp)) - } - OutlinedButton( - onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) }, - modifier = Modifier.weight(1f).height(46.dp), - border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))), - shape = RoundedCornerShape(14.dp), - contentPadding = PaddingValues(0.dp), - ) { - Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent) - } - // Toggle edit mode for removing songs - OutlinedButton( - onClick = { editMode = !editMode }, - modifier = Modifier.weight(1f).height(46.dp), - border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( - brush = SolidColor(if (editMode) NsAccent else NsOnSurface3) - ), - shape = RoundedCornerShape(14.dp), - contentPadding = PaddingValues(0.dp), - ) { - Icon( - if (editMode) Icons.Default.Done else Icons.Default.Edit, - if (editMode) "Bearbeiten beenden" else "Titel entfernen", - modifier = Modifier.size(20.dp), - tint = if (editMode) NsAccent else NsOnSurface2, - ) - } - // Download button — tappable unless fully done; resumes / re-downloads missing songs - val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false - OutlinedButton( - onClick = { if (!dlDone) vm.downloadPlaylist() }, - modifier = Modifier.weight(1f).height(46.dp), - border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( - brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3) - ), - shape = RoundedCornerShape(14.dp), - contentPadding = PaddingValues(0.dp), - ) { - when { - dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent) - isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent) - else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2) - } - } - } - - HorizontalDivider(color = NsBorder) - } - - itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song -> - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.weight(1f)) { - SongRow( - song = song, - coverUrl = coverUrl(song.coverArt), - isPlaying = playerState.currentSong?.id == song.id, - starred = song.starred != null, - onPlay = { onPlay(pl.entry, idx) }, - onStarToggle = { onStarToggle(song) }, - onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null, - ) - } - if (editMode) { - IconButton( - onClick = { vm.removeSong(idx) }, - modifier = Modifier.padding(end = 4.dp), ) { Icon( - Icons.Default.RemoveCircleOutline, - "Entfernen", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(22.dp), + Icons.AutoMirrored.Filled.ArrowBack, "Zurück", + tint = Color.White, modifier = Modifier.size(18.dp), ) } + // Edit + Delete buttons (top-right) + Row( + Modifier + .statusBarsPadding() + .padding(end = 14.dp, top = 14.dp) + .align(Alignment.TopEnd), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .clickable { showRenameDialog = true }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Edit, "Bearbeiten", + tint = Color.White, modifier = Modifier.size(18.dp)) + } + Box( + Modifier + .size(36.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.45f)) + .clickable { showDeleteDialog = true }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Default.Delete, "Löschen", + tint = Color.White, modifier = Modifier.size(18.dp)) + } + } + Column( + Modifier + .align(Alignment.BottomStart) + .padding(horizontal = 20.dp, vertical = 20.dp), + ) { + Text( + pl.name, + fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, + fontSize = 26.sp, color = Color.White, + maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + // Der Besitzer stand hier bisher bei JEDER Liste, auch bei den eigenen, und ohne + // Beschriftung — bei einer geteilten Liste ist genau das die interessante Angabe. + val meta = listOfNotNull( + if (pl.songCount > 0) "${pl.songCount} Songs" else null, + pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }?.let { "von $it" }, + ).joinToString(" · ") + if (meta.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + meta, fontFamily = Outfit, fontSize = 13.sp, + color = Color.White.copy(alpha = 0.65f), + ) + } + if (!pl.comment.isNullOrBlank()) { + Spacer(Modifier.height(3.dp)) + Text( + pl.comment!!, fontFamily = Outfit, fontSize = 12.sp, + color = Color.White.copy(alpha = 0.45f), + maxLines = 2, overflow = TextOverflow.Ellipsis, + ) + } + } + } + + // ── Play / Shuffle / Edit / Download ────────────────────────────── + // Four buttons in one row left no room for labels (the Play/Shuffle text was being + // clipped away), so all four are equal-width and icon-only. Play stays filled as the + // primary action; the rest are outlined and tint accent when active/done. + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Button( + onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) }, + modifier = Modifier.weight(1f).height(46.dp), + colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(0.dp), + ) { + Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp)) + } + OutlinedButton( + onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) }, + modifier = Modifier.weight(1f).height(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(0.dp), + ) { + Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent) + } + // Toggle edit mode for removing songs + OutlinedButton( + onClick = { editMode = !editMode }, + modifier = Modifier.weight(1f).height(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( + brush = SolidColor(if (editMode) NsAccent else NsOnSurface3) + ), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(0.dp), + ) { + Icon( + if (editMode) Icons.Default.Done else Icons.Default.Edit, + if (editMode) "Bearbeiten beenden" else "Titel entfernen", + modifier = Modifier.size(20.dp), + tint = if (editMode) NsAccent else NsOnSurface2, + ) + } + // Download button — tappable unless fully done; resumes / re-downloads missing songs + val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false + OutlinedButton( + onClick = { if (!dlDone) vm.downloadPlaylist() }, + modifier = Modifier.weight(1f).height(46.dp), + border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy( + brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3) + ), + shape = RoundedCornerShape(14.dp), + contentPadding = PaddingValues(0.dp), + ) { + when { + dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent) + isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent) + else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2) + } + } + } + + HorizontalDivider(color = NsBorder) + } + + itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { + SongRow( + song = song, + coverUrl = coverUrl(song.coverArt), + isPlaying = playerState.currentSong?.id == song.id, + starred = song.starred != null, + onPlay = { onPlay(pl.entry, idx) }, + onStarToggle = { onStarToggle(song) }, + onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null, + ) + } + if (editMode) { + IconButton( + onClick = { vm.removeSong(idx) }, + modifier = Modifier.padding(end = 4.dp), + ) { + Icon( + Icons.Default.RemoveCircleOutline, + "Entfernen", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(22.dp), + ) + } + } } } + item { Spacer(Modifier.height(16.dp)) } } - item { Spacer(Modifier.height(16.dp)) } + Rollbalken( + state = listState, + modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp), + farbe = NsOnSurface2, + ) } // ── Rename dialog ───────────────────────────────────────────────────────── diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt index 7f833f7..2783f35 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed @@ -28,6 +29,7 @@ import com.novasonic.app.app import com.novasonic.app.player.PlayerState import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.theme.* +import com.novasonic.app.ui.component.Rollbalken enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST } @@ -142,58 +144,66 @@ fun StarredScreen( Text("Noch keine Favoriten", color = NsOnSurface2) } } else { - LazyColumn { - if (gefolgteKuenstler.isNotEmpty()) { - item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") } - item { - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - // Die LazyRow baut zwar nur Sichtbares, aber eine endlose Reihe ist - // zum Durchsehen untauglich — der Rest gehoert in die gefilterte - // Kuenstlerliste. GEZEIGT deckt zwei Bildschirmbreiten ab. - items(gefolgteKuenstler.take(GEZEIGT), key = { it.id }) { a -> - GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) } - } - if (gefolgteKuenstler.size > GEZEIGT) { - item(key = "alle_kuenstler") { - AlleAnzeigen(gefolgteKuenstler.size - GEZEIGT, onOpenFollowed) + val listState = rememberLazyListState() + Box(Modifier.fillMaxSize()) { + LazyColumn(state = listState) { + if (gefolgteKuenstler.isNotEmpty()) { + item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") } + item { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Die LazyRow baut zwar nur Sichtbares, aber eine endlose Reihe ist + // zum Durchsehen untauglich — der Rest gehoert in die gefilterte + // Kuenstlerliste. GEZEIGT deckt zwei Bildschirmbreiten ab. + items(gefolgteKuenstler.take(GEZEIGT), key = { it.id }) { a -> + GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) } + } + if (gefolgteKuenstler.size > GEZEIGT) { + item(key = "alle_kuenstler") { + AlleAnzeigen(gefolgteKuenstler.size - GEZEIGT, onOpenFollowed) + } } } + Spacer(Modifier.height(8.dp)) } - Spacer(Modifier.height(8.dp)) } - } - if (gemerkteAlben.isNotEmpty()) { - item { FavoritenLabel("Gemerkte Alben (${gemerkteAlben.size})") } - item { - LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(gemerkteAlben, key = { it.id }) { al -> - AlbumCard(album = al, coverUrl = coverUrl(al.coverArt)) { onAlbumClick(al.id) } + if (gemerkteAlben.isNotEmpty()) { + item { FavoritenLabel("Gemerkte Alben (${gemerkteAlben.size})") } + item { + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(gemerkteAlben, key = { it.id }) { al -> + AlbumCard(album = al, coverUrl = coverUrl(al.coverArt)) { onAlbumClick(al.id) } + } } + Spacer(Modifier.height(8.dp)) } - Spacer(Modifier.height(8.dp)) } + if (sorted.isNotEmpty() && (gefolgteKuenstler.isNotEmpty() || gemerkteAlben.isNotEmpty())) { + item { FavoritenLabel("Songs (${sorted.size})") } + } + itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song -> + SongRow( + song = song, + coverUrl = coverUrl(song.coverArt), + isPlaying = playerState.currentSong?.id == song.id, + starred = song.starred != null, + onPlay = { onPlay(sorted, idx) }, + onStarToggle = { onStarToggle(song) }, + onMore = onShowContextMenu?.let { cb -> { cb(song) } }, + ) + } + item { Spacer(Modifier.height(8.dp)) } } - if (sorted.isNotEmpty() && (gefolgteKuenstler.isNotEmpty() || gemerkteAlben.isNotEmpty())) { - item { FavoritenLabel("Songs (${sorted.size})") } - } - itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song -> - SongRow( - song = song, - coverUrl = coverUrl(song.coverArt), - isPlaying = playerState.currentSong?.id == song.id, - starred = song.starred != null, - onPlay = { onPlay(sorted, idx) }, - onStarToggle = { onStarToggle(song) }, - onMore = onShowContextMenu?.let { cb -> { cb(song) } }, - ) - } - item { Spacer(Modifier.height(8.dp)) } + Rollbalken( + state = listState, + modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp), + farbe = NsOnSurface2, + ) } } }