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:
1 parent
820a75f40b
commit
72feac41bb
4 files changed
+156
-43
No files matched your search
@@ -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,9 +95,8 @@ 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) }
|
|
||||||
|
|
||||||
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
|
||||||
DisposableEffect(showNowPlaying, backDispatcher) {
|
DisposableEffect(showNowPlaying, backDispatcher) {
|
||||||
@@ -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,25 +278,21 @@ 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 {
|
||||||
absClient = client,
|
AbsBooksScreen(
|
||||||
library = selectedAbsLibrary!!,
|
absClient = client,
|
||||||
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() }
|
||||||
|
|||||||
@@ -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
|
||||||
|
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)) {
|
Box(Modifier.fillMaxSize().background(NsBackground)) {
|
||||||
// Blurred album art full-screen background — crossfade prevents transparent flash on song change
|
// Dominant-color gradient background
|
||||||
AsyncImage(
|
|
||||||
model = ImageRequest.Builder(context).data(coverUrl).crossfade(500).build(),
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier.fillMaxSize().blur(72.dp),
|
|
||||||
)
|
|
||||||
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,
|
||||||
|
|||||||
Reference in new issue
Block a user