feat(android): Rollbalken zum Ziehen in den langen Listen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6652fb4d2b
commit
1aa218f3e3
7 files changed
+681
-517
No files matched your search
@@ -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)) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user