feat(android): Rollbalken zum Ziehen in den langen Listen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-22 20:13:10 +02:00
1 parent 6652fb4d2b
commit 1aa218f3e3
7 files changed
+681 -517

No files matched your search

+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app" applicationId = "com.novasonic.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 18 versionCode = 19
versionName = "1.15" versionName = "1.16"
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in // Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der // gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
// Platzhalter stehen und der App-Link greift schlicht nicht. // Platzhalter stehen und der App-Link greift schlicht nicht.
@@ -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))
)
}
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.component.SongRow
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumViewModel import com.novasonic.app.viewmodel.AlbumViewModel
import com.novasonic.app.ui.component.Rollbalken
@Composable @Composable
fun AlbumScreen( fun AlbumScreen(
@@ -75,169 +77,177 @@ fun AlbumScreen(
return return
} }
LazyColumn(Modifier.fillMaxSize()) { val listState = rememberLazyListState()
item { Box(Modifier.fillMaxSize()) {
// ── Hero ───────────────────────────────────────────────────────── LazyColumn(Modifier.fillMaxSize(), state = listState) {
Box(Modifier.fillMaxWidth()) { item {
AsyncImage( // ── Hero ─────────────────────────────────────────────────────────
model = coverUrl(album.coverArt), Box(Modifier.fillMaxWidth()) {
contentDescription = album.name, AsyncImage(
contentScale = ContentScale.Crop, model = coverUrl(album.coverArt),
modifier = Modifier contentDescription = album.name,
.fillMaxWidth() contentScale = ContentScale.Crop,
.aspectRatio(1f) modifier = Modifier
.background(NsSurface2), .fillMaxWidth()
) .aspectRatio(1f)
// gradient overlay .background(NsSurface2),
Box( )
Modifier // gradient overlay
.fillMaxWidth() Box(
.aspectRatio(1f) Modifier
.background( .fillMaxWidth()
Brush.verticalGradient( .aspectRatio(1f)
0.0f to Color.Transparent, .background(
0.40f to NsBackground.copy(alpha = 0.35f), Brush.verticalGradient(
1.0f to NsBackground, 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),
)
}
}
} }
item { Spacer(Modifier.height(16.dp)) }
// ── 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)
} }
Rollbalken(
itemsIndexed(album.song, key = { _, s -> s.id }) { index, song -> state = listState,
SongRow( modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
song = song, farbe = NsOnSurface2,
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)) }
} }
} }
@@ -2,6 +2,7 @@
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.player.PlayerState
import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.ui.component.Rollbalken
@Composable @Composable
fun GenreSongsScreen( fun GenreSongsScreen(
@@ -77,19 +79,29 @@ fun GenreSongsScreen(
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text("Keine Songs gefunden", color = NsOnSurface2) Text("Keine Songs gefunden", color = NsOnSurface2)
} }
else -> LazyColumn { else -> {
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song -> val listState = rememberLazyListState()
SongRow( Box(Modifier.fillMaxSize()) {
song = song, LazyColumn(Modifier.fillMaxSize(), state = listState) {
coverUrl = coverUrl(song.coverArt), itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
isPlaying = playerState.currentSong?.id == song.id, SongRow(
starred = song.starred != null, song = song,
onPlay = { onPlay(songs, idx) }, coverUrl = coverUrl(song.coverArt),
onStarToggle = { onStarToggle(song) }, isPlaying = playerState.currentSong?.id == song.id,
onMore = onShowContextMenu?.let { cb -> { cb(song) } }, 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)) }
} }
} }
} }
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.component.SongRow
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.OfflineDetailViewModel import com.novasonic.app.viewmodel.OfflineDetailViewModel
import com.novasonic.app.ui.component.Rollbalken
@Composable @Composable
fun OfflineDetailScreen( fun OfflineDetailScreen(
@@ -78,131 +80,139 @@ fun OfflineDetailScreen(
onPlay(liste, i) onPlay(liste, i)
} }
LazyColumn(Modifier.fillMaxSize()) { val listState = rememberLazyListState()
item { Box(Modifier.fillMaxSize()) {
// ── Hero ───────────────────────────────────────────────────────── LazyColumn(Modifier.fillMaxSize(), state = listState) {
Box(Modifier.fillMaxWidth()) { item {
AsyncImage( // ── Hero ─────────────────────────────────────────────────────────
model = coverUrl(g.coverArt), Box(Modifier.fillMaxWidth()) {
contentDescription = g.name, AsyncImage(
contentScale = ContentScale.Crop, model = coverUrl(g.coverArt),
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2), contentDescription = g.name,
) contentScale = ContentScale.Crop,
Box( modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2),
Modifier )
.fillMaxWidth() Box(
.aspectRatio(1f) Modifier
.background( .fillMaxWidth()
Brush.verticalGradient( .aspectRatio(1f)
0.0f to Color.Transparent, .background(
0.40f to NsBackground.copy(alpha = 0.35f), Brush.verticalGradient(
1.0f to NsBackground, 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,
) )
) Spacer(Modifier.height(4.dp))
Box( Text(
Modifier "Offline · ${tracks.size} Titel",
.statusBarsPadding() fontFamily = Outfit, fontSize = 13.sp,
.padding(start = 14.dp, top = 14.dp) color = Color.White.copy(alpha = 0.65f),
.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), // ── Play / Shuffle / Edit row ─────────────────────────────────────
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text( Button(
g.name, onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, 0) },
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, modifier = Modifier.weight(1f).height(46.dp),
fontSize = 26.sp, color = Color.White, colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
maxLines = 2, overflow = TextOverflow.Ellipsis, shape = RoundedCornerShape(14.dp),
) ) {
Spacer(Modifier.height(4.dp)) Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
Text( Spacer(Modifier.width(6.dp))
"Offline · ${tracks.size} Titel", Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
fontFamily = Outfit, fontSize = 13.sp, }
color = Color.White.copy(alpha = 0.65f), 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 ───────────────────────────────────── itemsIndexed(songs, key = { _, s -> s.id }) { index, song ->
Row( Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), Box(Modifier.weight(1f)) {
horizontalArrangement = Arrangement.spacedBy(10.dp), SongRow(
) { song = song,
Button( coverUrl = coverUrl(song.coverArt),
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, 0) }, isPlaying = playerState.currentSong?.id == song.id,
modifier = Modifier.weight(1f).height(46.dp), // Position in der abspielbaren Liste suchen — die Anzeige enthält auch
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White), // Titel, die noch fehlen, die Indizes decken sich also nicht.
shape = RoundedCornerShape(14.dp), onPlay = {
) { val i = spielbar.indexOfFirst { it.id == song.id }
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp)) if (i >= 0) onPlay(spielbar, i)
Spacer(Modifier.width(6.dp)) else android.widget.Toast.makeText(
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp) ctx, "Dieser Titel ist noch nicht heruntergeladen",
} android.widget.Toast.LENGTH_SHORT).show()
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))), if (editMode) {
shape = RoundedCornerShape(14.dp), IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) {
) { Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen",
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent) tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp))
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))
} }
} }
} }
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,
)
} }
} }
@@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.component.SongRow
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.PlaylistViewModel import com.novasonic.app.viewmodel.PlaylistViewModel
import com.novasonic.app.ui.component.Rollbalken
@Composable @Composable
fun PlaylistDetailScreen( fun PlaylistDetailScreen(
@@ -91,207 +93,215 @@ fun PlaylistDetailScreen(
return return
} }
LazyColumn(Modifier.fillMaxSize()) { val listState = rememberLazyListState()
item { Box(Modifier.fillMaxSize()) {
// ── Hero ───────────────────────────────────────────────────────── LazyColumn(Modifier.fillMaxSize(), state = listState) {
Box(Modifier.fillMaxWidth()) { item {
AsyncImage( // ── Hero ─────────────────────────────────────────────────────────
model = coverUrl(pl.coverArt), Box(Modifier.fillMaxWidth()) {
contentDescription = pl.name, AsyncImage(
contentScale = ContentScale.Crop, model = coverUrl(pl.coverArt),
modifier = Modifier contentDescription = pl.name,
.fillMaxWidth() contentScale = ContentScale.Crop,
.aspectRatio(1f) modifier = Modifier
.background(NsSurface2), .fillMaxWidth()
) .aspectRatio(1f)
Box( .background(NsSurface2),
Modifier )
.fillMaxWidth() Box(
.aspectRatio(1f) Modifier
.background( .fillMaxWidth()
Brush.verticalGradient( .aspectRatio(1f)
0.0f to Color.Transparent, .background(
0.40f to NsBackground.copy(alpha = 0.35f), Brush.verticalGradient(
1.0f to NsBackground, 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( Box(
Modifier Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp) .size(36.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f)) .background(Color.Black.copy(alpha = 0.45f))
.clickable { showRenameDialog = true }, .clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center, 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( Icon(
Icons.Default.RemoveCircleOutline, Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
"Entfernen", tint = Color.White, modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(22.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 ───────────────────────────────────────────────────────── // ── Rename dialog ─────────────────────────────────────────────────────────
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed 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.player.PlayerState
import com.novasonic.app.ui.component.SongRow import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.* import com.novasonic.app.ui.theme.*
import com.novasonic.app.ui.component.Rollbalken
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST } enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
@@ -142,58 +144,66 @@ fun StarredScreen(
Text("Noch keine Favoriten", color = NsOnSurface2) Text("Noch keine Favoriten", color = NsOnSurface2)
} }
} else { } else {
LazyColumn { val listState = rememberLazyListState()
if (gefolgteKuenstler.isNotEmpty()) { Box(Modifier.fillMaxSize()) {
item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") } LazyColumn(state = listState) {
item { if (gefolgteKuenstler.isNotEmpty()) {
LazyRow( item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") }
contentPadding = PaddingValues(horizontal = 16.dp), item {
horizontalArrangement = Arrangement.spacedBy(12.dp), LazyRow(
) { contentPadding = PaddingValues(horizontal = 16.dp),
// Die LazyRow baut zwar nur Sichtbares, aber eine endlose Reihe ist horizontalArrangement = Arrangement.spacedBy(12.dp),
// zum Durchsehen untauglich — der Rest gehoert in die gefilterte ) {
// Kuenstlerliste. GEZEIGT deckt zwei Bildschirmbreiten ab. // Die LazyRow baut zwar nur Sichtbares, aber eine endlose Reihe ist
items(gefolgteKuenstler.take(GEZEIGT), key = { it.id }) { a -> // zum Durchsehen untauglich — der Rest gehoert in die gefilterte
GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) } // Kuenstlerliste. GEZEIGT deckt zwei Bildschirmbreiten ab.
} items(gefolgteKuenstler.take(GEZEIGT), key = { it.id }) { a ->
if (gefolgteKuenstler.size > GEZEIGT) { GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) }
item(key = "alle_kuenstler") { }
AlleAnzeigen(gefolgteKuenstler.size - GEZEIGT, onOpenFollowed) 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()) {
if (gemerkteAlben.isNotEmpty()) { item { FavoritenLabel("Gemerkte Alben (${gemerkteAlben.size})") }
item { FavoritenLabel("Gemerkte Alben (${gemerkteAlben.size})") } item {
item { LazyRow(
LazyRow( contentPadding = PaddingValues(horizontal = 16.dp),
contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), ) {
) { items(gemerkteAlben, key = { it.id }) { al ->
items(gemerkteAlben, key = { it.id }) { al -> AlbumCard(album = al, coverUrl = coverUrl(al.coverArt)) { onAlbumClick(al.id) }
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())) { Rollbalken(
item { FavoritenLabel("Songs (${sorted.size})") } state = listState,
} modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song -> farbe = NsOnSurface2,
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)) }
} }
} }
} }