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 // Coil
implementation(libs.coil.compose) implementation(libs.coil.compose)
// Palette (dominant cover color extraction in NowPlaying)
implementation("androidx.palette:palette-ktx:1.0.0")
// Google Fonts (Outfit) // Google Fonts (Outfit)
implementation(libs.compose.ui.google.fonts) implementation(libs.compose.ui.google.fonts)
} }
@@ -30,7 +30,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.* import androidx.navigation.*
import androidx.navigation.compose.* import androidx.navigation.compose.*
import com.novasonic.app.api.AbsClient import com.novasonic.app.api.AbsClient
import com.novasonic.app.api.AbsLibrary
import com.novasonic.app.api.AbsLibraryItem import com.novasonic.app.api.AbsLibraryItem
import com.novasonic.app.api.model.Song import com.novasonic.app.api.model.Song
import com.novasonic.app.player.AbsPlayerController 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"), LIBRARY("library", Icons.Default.LibraryMusic, "Bibliothek"),
ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"), ERKUNDEN("erkunden", Icons.Default.Explore, "Erkunden"),
STARRED("starred", Icons.Default.Favorite, "Favoriten"), STARRED("starred", Icons.Default.Favorite, "Favoriten"),
ABS("abs", Icons.Default.Book, "Hörbücher"), ABS("abs", Icons.Default.Book, "Bücher"),
SETTINGS("settings", Icons.Default.Settings, "Einstellungen"), SETTINGS("settings", Icons.Default.Settings, "Einst."),
} }
private val NavBg = Color(0xF7080814) private val NavBg = Color(0xF7080814)
@@ -96,7 +95,6 @@ fun AppNavigation() {
var playlistPickerSong by remember { mutableStateOf<Song?>(null) } var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
var infoSheetSong by remember { mutableStateOf<Song?>(null) } var infoSheetSong by remember { mutableStateOf<Song?>(null) }
var showNotifications by remember { mutableStateOf(false) } var showNotifications by remember { mutableStateOf(false) }
var selectedAbsLibrary by remember { mutableStateOf<AbsLibrary?>(null) }
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) } var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
var showAbsPlayer by remember { mutableStateOf(false) } var showAbsPlayer by remember { mutableStateOf(false) }
@@ -125,14 +123,6 @@ fun AppNavigation() {
backDispatcher.addCallback(cb) backDispatcher.addCallback(cb)
onDispose { cb.remove() } 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 coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000) fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
@@ -210,7 +200,7 @@ fun AppNavigation() {
} }
}, },
icon = { Icon(dest.icon, contentDescription = dest.label) }, 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( colors = NavigationBarItemDefaults.colors(
selectedIconColor = NsAccent, selectedIconColor = NsAccent,
selectedTextColor = NsAccent, selectedTextColor = NsAccent,
@@ -288,27 +278,23 @@ fun AppNavigation() {
} }
composable(TopLevel.ABS.route) { composable(TopLevel.ABS.route) {
val client = absClient val client = absClient
when { if (client != null) {
client == null -> {} if (selectedAbsItem != null) {
selectedAbsItem != null -> AbsBookDetailScreen( AbsBookDetailScreen(
absClient = client, absClient = client,
libraryItem = selectedAbsItem!!, libraryItem = selectedAbsItem!!,
absPlayerController = absPlayerController, absPlayerController = absPlayerController,
onPlay = { showAbsPlayer = true }, onPlay = { showAbsPlayer = true },
onBack = { selectedAbsItem = null }, onBack = { selectedAbsItem = null },
) )
selectedAbsLibrary != null -> AbsLibraryScreen( } else {
AbsBooksScreen(
absClient = client, absClient = client,
library = selectedAbsLibrary!!,
onItemClick = { selectedAbsItem = it }, onItemClick = { selectedAbsItem = it },
onBack = { selectedAbsLibrary = null },
)
else -> AbsScreen(
absClient = client,
onLibraryClick = { selectedAbsLibrary = it },
) )
} }
} }
}
composable(TopLevel.SETTINGS.route) { SettingsScreen() } composable(TopLevel.SETTINGS.route) { SettingsScreen() }
composable( composable(
@@ -9,11 +9,13 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.horizontalScroll
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.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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 @Composable
private fun LibraryRow(library: AbsLibrary, onClick: () -> Unit) { private fun LibraryRow(library: AbsLibrary, onClick: () -> Unit) {
Row( Row(
@@ -33,9 +33,15 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity 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.ImageRequest
import coil.request.SuccessResult
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -70,7 +76,7 @@ fun NowPlayingScreen(
var tab by remember { mutableStateOf(NpTab.PLAYER) } var tab by remember { mutableStateOf(NpTab.PLAYER) }
val statusBarTop: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getTop(this).toDp() } 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() } val navBarBottom: Dp = with(LocalDensity.current) { WindowInsets.safeDrawing.getBottom(this).toDp() }
// Apple Music-style: cover scales up while playing // Apple Music-style: cover scales up while playing
@@ -83,20 +89,45 @@ fun NowPlayingScreen(
label = "coverScale", label = "coverScale",
) )
// Extract dominant color from cover art for background tinting
val context = LocalContext.current val context = LocalContext.current
Box(Modifier.fillMaxSize().background(NsBackground)) { var dominantColor by remember(coverUrl) { mutableStateOf<Color?>(null) }
// Blurred album art full-screen background — crossfade prevents transparent flash on song change LaunchedEffect(coverUrl) {
AsyncImage( dominantColor = null
model = ImageRequest.Builder(context).data(coverUrl).crossfade(500).build(), if (coverUrl.isNullOrBlank()) return@LaunchedEffect
contentDescription = null, try {
contentScale = ContentScale.Crop, val loader = ImageLoader(context)
modifier = Modifier.fillMaxSize().blur(72.dp), 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( Box(
Modifier.fillMaxSize().background( 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( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,