diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1672e75..ebb5d50 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index 44e60db..aa504e1 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -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,9 +95,8 @@ fun AppNavigation() { var playlistPickerSong by remember { mutableStateOf(null) } var infoSheetSong by remember { mutableStateOf(null) } var showNotifications by remember { mutableStateOf(false) } - var selectedAbsLibrary by remember { mutableStateOf(null) } - var selectedAbsItem by remember { mutableStateOf(null) } - var showAbsPlayer by remember { mutableStateOf(false) } + var selectedAbsItem by remember { mutableStateOf(null) } + var showAbsPlayer by remember { mutableStateOf(false) } val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher DisposableEffect(showNowPlaying, backDispatcher) { @@ -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,25 +278,21 @@ fun AppNavigation() { } composable(TopLevel.ABS.route) { val client = absClient - when { - client == null -> {} - selectedAbsItem != null -> AbsBookDetailScreen( - absClient = client, - libraryItem = selectedAbsItem!!, - absPlayerController = absPlayerController, - onPlay = { showAbsPlayer = true }, - onBack = { selectedAbsItem = null }, - ) - selectedAbsLibrary != null -> AbsLibraryScreen( - absClient = client, - library = selectedAbsLibrary!!, - onItemClick = { selectedAbsItem = it }, - onBack = { selectedAbsLibrary = null }, - ) - else -> AbsScreen( - absClient = client, - onLibraryClick = { selectedAbsLibrary = it }, - ) + if (client != null) { + if (selectedAbsItem != null) { + AbsBookDetailScreen( + absClient = client, + libraryItem = selectedAbsItem!!, + absPlayerController = absPlayerController, + onPlay = { showAbsPlayer = true }, + onBack = { selectedAbsItem = null }, + ) + } else { + AbsBooksScreen( + absClient = client, + onItemClick = { selectedAbsItem = it }, + ) + } } } composable(TopLevel.SETTINGS.route) { SettingsScreen() } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AbsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AbsScreen.kt index 8f89a0b..624bb26 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AbsScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/AbsScreen.kt @@ -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>(emptyList()) } + var selectedLibIdx by remember { mutableIntStateOf(0) } + var items by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(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( diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt index c3cc147..bfa3dc5 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/NowPlayingScreen.kt @@ -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 + var dominantColor by remember(coverUrl) { mutableStateOf(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)) { - // 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), - ) + // 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,