fix: ABS skip library list + nav label overflow + NowPlaying top bar + dominant color bg

- ABS: new AbsBooksScreen loads books directly on tab open; library pills shown only when >1 library — no more two-step library→books navigation
- Nav: shorten "Hörbücher"→"Bücher", "Einstellungen"→"Einst.", reduce label font to 9sp with maxLines=1 to prevent label wrapping
- NowPlaying: increase top padding so the tab bar sits lower on screen (statusBarTop + 16dp, min 52dp)
- NowPlaying: replace blurred-cover background with dominant-color gradient extracted via Palette from cover art; animates on track change; falls back to dark color

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-06-16 00:23:43 +02:00
1 parent 820a75f40b
commit 72feac41bb
4 files changed
+145 -32

No files matched your search

+3
View File
@@ -93,6 +93,9 @@ dependencies {
// Coil
implementation(libs.coil.compose)
// Palette (dominant cover color extraction in NowPlaying)
implementation("androidx.palette:palette-ktx:1.0.0")
// Google Fonts (Outfit)
implementation(libs.compose.ui.google.fonts)
}
@@ -30,7 +30,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.*
import androidx.navigation.compose.*
import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibrary
import com.novasonic.app.api.AbsLibraryItem
import com.novasonic.app.api.model.Song
import com.novasonic.app.player.AbsPlayerController
@@ -50,8 +49,8 @@ private enum class TopLevel(val route: String, val icon: ImageVector, val label:
LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
STARRED("starred", Icons.Default.Favorite, "Favoriten"),
ABS("abs", Icons.Default.Book, "Hörbücher"),
SETTINGS("settings", Icons.Default.Settings, "Einstellungen"),
ABS("abs", Icons.Default.Book, "Bücher"),
SETTINGS("settings", Icons.Default.Settings, "Einst."),
}
private val NavBg = Color(0xF7080814)
@@ -96,7 +95,6 @@ fun AppNavigation() {
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
var showNotifications by remember { mutableStateOf(false) }
var selectedAbsLibrary by remember { mutableStateOf<AbsLibrary?>(null) }
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
var showAbsPlayer by remember { mutableStateOf(false) }
@@ -125,14 +123,6 @@ fun AppNavigation() {
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
DisposableEffect(selectedAbsLibrary, backDispatcher) {
if (selectedAbsLibrary == null || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { selectedAbsLibrary = null }
}
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
@@ -210,7 +200,7 @@ fun AppNavigation() {
}
},
icon = { Icon(dest.icon, contentDescription = dest.label) },
label = { Text(dest.label, style = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 10.sp)) },
label = { Text(dest.label, style = TextStyle(fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 9.sp), maxLines = 1) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = NsAccent,
selectedTextColor = NsAccent,
@@ -288,27 +278,23 @@ fun AppNavigation() {
}
composable(TopLevel.ABS.route) {
val client = absClient
when {
client == null -> {}
selectedAbsItem != null -> AbsBookDetailScreen(
if (client != null) {
if (selectedAbsItem != null) {
AbsBookDetailScreen(
absClient = client,
libraryItem = selectedAbsItem!!,
absPlayerController = absPlayerController,
onPlay = { showAbsPlayer = true },
onBack = { selectedAbsItem = null },
)
selectedAbsLibrary != null -> AbsLibraryScreen(
} else {
AbsBooksScreen(
absClient = client,
library = selectedAbsLibrary!!,
onItemClick = { selectedAbsItem = it },
onBack = { selectedAbsLibrary = null },
)
else -> AbsScreen(
absClient = client,
onLibraryClick = { selectedAbsLibrary = it },
)
}
}
}
composable(TopLevel.SETTINGS.route) { SettingsScreen() }
composable(
@@ -9,11 +9,13 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -92,6 +94,97 @@ fun AbsScreen(
}
}
// ── Combined books screen (skips library list, auto-loads first library) ─────
@Composable
fun AbsBooksScreen(
absClient: AbsClient,
onItemClick: (AbsLibraryItem) -> Unit,
) {
var libraries by remember { mutableStateOf<List<AbsLibrary>>(emptyList()) }
var selectedLibIdx by remember { mutableIntStateOf(0) }
var items by remember { mutableStateOf<List<AbsLibraryItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
// Initial load: fetch libraries, then immediately load first library's items
LaunchedEffect(Unit) {
isLoading = true
val libs = runCatching { absClient.getLibraries() }.getOrDefault(emptyList())
libraries = libs
val first = libs.firstOrNull()
if (first != null) {
val r = runCatching { absClient.getLibraryItems(first.id).results }
items = r.getOrDefault(emptyList())
error = r.exceptionOrNull()?.message
}
isLoading = false
}
// Reload when user switches library tab (only after initial load)
LaunchedEffect(selectedLibIdx) {
val lib = libraries.getOrNull(selectedLibIdx) ?: return@LaunchedEffect
isLoading = true
error = null
val r = runCatching { absClient.getLibraryItems(lib.id).results }
items = r.getOrDefault(emptyList())
error = r.exceptionOrNull()?.message
isLoading = false
}
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 16.dp)) {
Text(
"Hörbücher",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = NsOnSurface,
)
// Library switcher — only shown when more than one library exists
if (libraries.size > 1) {
Spacer(Modifier.height(10.dp))
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
libraries.forEachIndexed { idx, lib ->
val sel = idx == selectedLibIdx
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(if (sel) NsAccent else NsSurface)
.clickable { selectedLibIdx = idx }
.padding(horizontal = 14.dp, vertical = 6.dp),
) {
Text(
lib.name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = if (sel) NsBackground else NsOnSurface2,
)
}
}
}
}
Spacer(Modifier.height(12.dp))
when {
isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
error != null -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(error ?: "Fehler", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
}
items.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Keine Bücher gefunden", fontFamily = Outfit, color = NsOnSurface2)
}
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(items) { item ->
AbsItemRow(item = item, absClient = absClient, onClick = { onItemClick(item) })
}
}
}
}
}
@Composable
private fun LibraryRow(library: AbsLibrary, onClick: () -> Unit) {
Row(
@@ -33,9 +33,15 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import android.graphics.drawable.BitmapDrawable
import androidx.palette.graphics.Palette
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -70,7 +76,7 @@ fun NowPlayingScreen(
var tab by remember { mutableStateOf(NpTab.PLAYER) }
val statusBarTop: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getTop(this).toDp() }
val topPad = maxOf(statusBarTop, 36.dp)
val topPad = maxOf(statusBarTop + 16.dp, 52.dp)
val navBarBottom: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getBottom(this).toDp() }
// Apple Music-style: cover scales up while playing
@@ -83,20 +89,45 @@ fun NowPlayingScreen(
label = "coverScale",
)
// Extract dominant color from cover art for background tinting
val context = LocalContext.current
Box(Modifier.fillMaxSize().background(NsBackground)) {
// Blurred album art full-screen background — crossfade prevents transparent flash on song change
AsyncImage(
model = ImageRequest.Builder(context).data(coverUrl).crossfade(500).build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().blur(72.dp),
var dominantColor by remember(coverUrl) { mutableStateOf<Color?>(null) }
LaunchedEffect(coverUrl) {
dominantColor = null
if (coverUrl.isNullOrBlank()) return@LaunchedEffect
try {
val loader = ImageLoader(context)
val req = ImageRequest.Builder(context).data(coverUrl).allowHardware(false).size(80).build()
val result = loader.execute(req)
if (result is SuccessResult) {
val bitmap = (result.drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect
val palette = withContext(Dispatchers.Default) { Palette.from(bitmap).generate() }
val swatch = palette.darkMutedSwatch ?: palette.mutedSwatch ?: palette.dominantSwatch
swatch?.rgb?.let { dominantColor = Color(it) }
}
} catch (_: Exception) {}
}
val rawBgColor = dominantColor ?: Color(0xFF0D0D1A)
val animatedBgColor by animateColorAsState(
targetValue = rawBgColor,
animationSpec = tween(600),
label = "npBg",
)
Box(Modifier.fillMaxSize().background(NsBackground)) {
// Dominant-color gradient background
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(0f to Color(0xCC000000), 1f to Color(0xF2000000))
Brush.verticalGradient(
0f to animatedBgColor,
0.55f to animatedBgColor.copy(alpha = 0.65f),
1f to NsBackground,
)
)
)
// Dark scrim for readability
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.28f)))
Column(
horizontalAlignment = Alignment.CenterHorizontally,