diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index a9f3cfc..c0f134b 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -70,6 +70,7 @@ import com.example.myapplication.ui.bottomBarContentPadding import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaIconView import com.example.myapplication.ui.design.LumaAsyncImage +import com.example.myapplication.ui.design.LumaMediaCard import com.example.myapplication.ui.design.LumaEmptyState import com.example.myapplication.ui.design.LumaErrorState import com.example.myapplication.ui.design.LumaSkeleton @@ -387,32 +388,18 @@ private fun MediaPoster( onItemClick: (EmbyItem) -> Unit = {}, ) { val posterUrl = EmbyRepository.buildImageUrl(serverUrl, item) - val fallbackColors = listOf(Color(0xFF455A64), Color(0xFF607D8B)) - - Column(modifier.width(115.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Box( - Modifier.fillMaxWidth().height(165.dp).clip(RoundedCornerShape(LumaShapes.small)) - .clickable { onItemClick(item) } - .background(Brush.verticalGradient(fallbackColors)), - ) { - if (posterUrl != null) { - LumaAsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(posterUrl).crossfade(true).build(), - contentDescription = item.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - } - Text( - text = item.seriesName ?: item.name, - color = homeContentColor(), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + LumaMediaCard( + model = posterUrl?.let { + ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build() + }, + title = item.seriesName ?: item.name, + modifier = modifier.width(115.dp), + imageAspectRatio = 115f / 165f, + titleColor = homeContentColor(), + titleSize = 12.sp, + titleSpacing = 4.dp, + onClick = { onItemClick(item) }, + ) } @Composable @@ -421,43 +408,36 @@ private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClic val imageUrl = backdropTag?.let { "$serverUrl/emby/Items/${item.id}/Images/Backdrop/0?tag=$it&maxWidth=700&quality=75" } ?: item.posterUrlPath?.let { "$serverUrl$it" } - val fallbackColors = listOf(Color(0xFF37474F), Color(0xFF546E7A)) - - Column(Modifier.width(220.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + val progress = item.runTimeTicks?.takeIf { it > 0L }?.let { runtimeTicks -> + (item.playbackPositionMs.toDouble() * 10_000.0 / runtimeTicks) + .toFloat() + .coerceIn(0f, 1f) + } + LumaMediaCard( + model = imageUrl?.let { + ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build() + }, + title = item.seriesName ?: item.name, + modifier = Modifier.width(220.dp), + imageAspectRatio = 16f / 9f, + titleColor = homeContentColor(), + titleSize = 13.sp, + titleWeight = FontWeight.SemiBold, + titleSpacing = 5.dp, + progress = progress, + onClick = { onItemClick(item) }, + ) { Box( - Modifier.fillMaxWidth().aspectRatio(16f / 9f) - .clip(RoundedCornerShape(LumaShapes.small)) - .clickable { onItemClick(item) } - .background(Brush.linearGradient(fallbackColors)), + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.20f)), + contentAlignment = Alignment.Center, ) { - if (imageUrl != null) { - LumaAsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(), - contentDescription = item.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - Box( - Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.2f)), - contentAlignment = Alignment.Center, - ) { - LumaIconView( - icon = LumaIcon.Play, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(36.dp), - ) - } + LumaIconView( + icon = LumaIcon.Play, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(36.dp), + ) } - Text( - text = item.seriesName ?: item.name, - color = homeContentColor(), - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index b41a9f7..3f0925b 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -8,10 +8,8 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -37,10 +35,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -56,11 +52,10 @@ import com.example.myapplication.ui.GlassTokens import com.example.myapplication.ui.LiquidGlassTopBar import com.example.myapplication.ui.SegmentedControl import com.example.myapplication.ui.bottomBarContentPadding -import com.example.myapplication.ui.design.LumaColors import com.example.myapplication.ui.design.LumaEmptyState import com.example.myapplication.ui.design.LumaErrorState import com.example.myapplication.ui.design.LumaIcon -import com.example.myapplication.ui.design.LumaAsyncImage +import com.example.myapplication.ui.design.LumaMediaCard import com.example.myapplication.ui.design.LumaSkeleton import com.example.myapplication.ui.design.LumaShapes import top.yukonga.miuix.kmp.basic.Text @@ -197,33 +192,14 @@ private fun RecordsEmptyState( @Composable private fun FavoriteMediaCard(item: EmbyItem, serverUrl: String, onClick: () -> Unit) { val imageUrl = EmbyRepository.buildImageUrl(serverUrl, item, maxWidth = 400) - Column( - modifier = Modifier.clickable(onClick = onClick), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(0.7f) - .clip(RoundedCornerShape(LumaShapes.small)) - .background(LumaColors.backgroundElevated) - ) { - if (imageUrl != null) { - LumaAsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(), - contentDescription = item.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - } - } - Text( - text = item.name, - color = GlassTokens.text, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + LumaMediaCard( + model = imageUrl?.let { + ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build() + }, + title = item.name, + titleColor = GlassTokens.text, + titleSize = 14.sp, + titleSpacing = 6.dp, + onClick = onClick, + ) } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt index 3725cc3..c354b7a 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt @@ -2,10 +2,8 @@ package com.example.myapplication.screens import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -28,9 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -49,7 +45,7 @@ import com.example.myapplication.ui.bottomBarContentPadding import com.example.myapplication.ui.design.LumaEmptyState import com.example.myapplication.ui.design.LumaErrorState import com.example.myapplication.ui.design.LumaIcon -import com.example.myapplication.ui.design.LumaAsyncImage +import com.example.myapplication.ui.design.LumaMediaCard import com.example.myapplication.ui.design.LumaSkeleton import kotlinx.coroutines.delay import top.yukonga.miuix.kmp.basic.Text @@ -191,36 +187,14 @@ private fun SearchEmptyState(title: String, subtitle: String) { @Composable private fun SearchMediaCard(item: EmbyItem, serverUrl: String, onClick: () -> Unit) { val imageUrl = EmbyRepository.buildImageUrl(serverUrl, item, maxWidth = 500) - val fallback = listOf(Color(0xFF344054), Color(0xFF667085)) - Column( - Modifier - .fillMaxWidth() - .clickable(onClick = onClick), - verticalArrangement = Arrangement.spacedBy(7.dp), - ) { - Box( - Modifier - .fillMaxWidth() - .aspectRatio(0.7f) - .clip(RoundedCornerShape(16.dp)) - .background(Brush.verticalGradient(fallback)), - ) { - if (imageUrl != null) { - LumaAsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(), - contentDescription = item.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - } - } - Text( - text = item.name, - color = GlassTokens.text, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + LumaMediaCard( + model = imageUrl?.let { + ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build() + }, + title = item.name, + titleColor = GlassTokens.text, + titleSize = 14.sp, + titleSpacing = 7.dp, + onClick = onClick, + ) } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt index 650ffee..13caa9e 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt @@ -74,13 +74,13 @@ fun LiquidAppBottomBar( selected = selectedTab == index, contentDescription = stringResource(tab.labelRes), tint = if (isGhostLayer) Color.White else if (selectedTab == index) LumaColors.accent else inactive, - modifier = Modifier.size(34.dp), + modifier = Modifier.size(40.dp), ) - Spacer(Modifier.height(3.dp)) + Spacer(Modifier.height(2.dp)) Text( text = stringResource(tab.labelRes), color = if (isGhostLayer) Color.White else if (selectedTab == index) LumaColors.accent else inactive, - fontSize = 10.sp, + fontSize = 8.sp, fontWeight = FontWeight.Medium, maxLines = 1, ) @@ -112,7 +112,7 @@ fun LiquidAppBottomBar( if (LumaColors.isDark) { Color(0xFF1C2430).copy(alpha = 0.54f) } else { - Color(0xFFE1E6EC).copy(alpha = 0.62f) + Color(0xFFD4D9DF).copy(alpha = 0.78f) }, ) if (searchSelected) { @@ -126,8 +126,10 @@ fun LiquidAppBottomBar( selected = searchSelected, neutralVeil = if (LumaColors.isDark) { Color(0xFF1C2430).copy(alpha = 0.72f) + } else if (AppTheme.glassMode == GlassMode.Liquid) { + Color(0xFFD7E3F0).copy(alpha = 0.58f) } else { - Color(0xFFE1E6EC).copy(alpha = 0.82f) + Color(0xFFD4D9DF).copy(alpha = 0.84f) }, ) }, @@ -151,12 +153,12 @@ fun LiquidAppBottomBar( icon = LumaIcon.Search, contentDescription = null, tint = if (searchSelected) LumaColors.accent else inactive, - modifier = Modifier.size(36.dp), + modifier = Modifier.size(44.dp), ) Text( text = stringResource(Res.string.search), color = if (searchSelected) LumaColors.accent else inactive, - fontSize = 10.sp, + fontSize = 8.sp, fontWeight = FontWeight.ExtraBold, ) } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt index 1f40161..dcb33a2 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -20,6 +21,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.awaitEachGesture @@ -38,6 +42,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.graphicsLayer @@ -48,16 +53,28 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.setProgress import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import coil.compose.AsyncImage +import coil.request.ImageRequest import top.yukonga.miuix.kmp.basic.Text import kotlin.math.abs +private enum class LumaImageState { + Loading, + Success, + Error, +} + @Composable fun LumaAsyncImage( model: Any?, @@ -66,14 +83,185 @@ fun LumaAsyncImage( contentScale: ContentScale = ContentScale.Crop, ) { val fallback = ColorPainter(LumaColors.backgroundElevated) - AsyncImage( - model = model, - contentDescription = contentDescription, - contentScale = contentScale, - modifier = modifier, - placeholder = fallback, - error = fallback, + val imageKey = (model as? ImageRequest)?.data ?: model + var imageState by remember(imageKey) { + mutableStateOf(if (model == null) LumaImageState.Error else LumaImageState.Loading) + } + Box(modifier) { + AsyncImage( + model = model, + contentDescription = contentDescription, + contentScale = contentScale, + modifier = Modifier.fillMaxSize(), + placeholder = fallback, + error = fallback, + onLoading = { imageState = LumaImageState.Loading }, + onSuccess = { imageState = LumaImageState.Success }, + onError = { imageState = LumaImageState.Error }, + ) + when (imageState) { + LumaImageState.Loading -> LumaImageLoadingPlaceholder() + LumaImageState.Success -> Unit + LumaImageState.Error -> LumaImageErrorPlaceholder() + } + } +} + +@Composable +private fun LumaImageLoadingPlaceholder() { + Box( + Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + listOf( + LumaColors.backgroundElevated, + LumaColors.background, + LumaColors.backgroundElevated, + ), + ), + ), + ) { + Column( + Modifier + .fillMaxSize() + .padding(LumaSpacing.sm), + verticalArrangement = Arrangement.Bottom, + ) { + Box( + Modifier + .fillMaxWidth(0.62f) + .height(8.dp) + .clip(RoundedCornerShape(4.dp)) + .background(LumaColors.textSecondary.copy(alpha = 0.16f)), + ) + Spacer(Modifier.height(LumaSpacing.xs)) + Box( + Modifier + .fillMaxWidth(0.38f) + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(LumaColors.textSecondary.copy(alpha = 0.12f)), + ) + } + } +} + +@Composable +private fun LumaImageErrorPlaceholder() { + Box( + Modifier + .fillMaxSize() + .background(LumaColors.backgroundElevated.copy(alpha = 0.96f)), + contentAlignment = Alignment.Center, + ) { + LumaIconView( + icon = LumaIcon.Image, + contentDescription = null, + tint = LumaColors.textSecondary, + modifier = Modifier.size(28.dp), + ) + } +} + +/** + * Shared media card surface. The image and title keep their natural geometry; + * only the card surface receives the small pressed-state feedback. + */ +@Composable +fun LumaMediaCard( + model: Any?, + title: String, + modifier: Modifier = Modifier, + imageAspectRatio: Float = 0.7f, + contentScale: ContentScale = ContentScale.Crop, + titleColor: Color = LumaColors.text, + titleSize: TextUnit = LumaTypography.body, + titleWeight: FontWeight = FontWeight.Medium, + titleSpacing: Dp = LumaSpacing.xs, + progress: Float? = null, + onClick: (() -> Unit)? = null, + overlay: @Composable BoxScope.() -> Unit = {}, +) { + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.985f else 1f, + animationSpec = tween(LumaMotion.fast), + label = "mediaCardPress", ) + + Column( + modifier = modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .then( + if (onClick != null) { + Modifier.clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onClick, + ) + } else { + Modifier + }, + ) + .semantics(mergeDescendants = true) { + contentDescription = title + }, + verticalArrangement = Arrangement.spacedBy(titleSpacing), + ) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(imageAspectRatio) + .clip(RoundedCornerShape(LumaShapes.small)) + .background( + Brush.verticalGradient( + listOf(LumaColors.backgroundElevated, LumaColors.background), + ), + ), + ) { + LumaAsyncImage( + model = model, + contentDescription = if (onClick == null) title else null, + contentScale = contentScale, + modifier = Modifier.fillMaxSize(), + ) + overlay() + progress?.let { value -> + val clamped = value.coerceIn(0f, 1f) + Box( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = LumaSpacing.xs, vertical = LumaSpacing.xs) + .height(3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(Color.White.copy(alpha = 0.30f)), + ) { + Box( + Modifier + .fillMaxWidth(clamped) + .height(3.dp) + .clip(RoundedCornerShape(2.dp)) + .background(LumaColors.accent), + ) + } + } + } + Text( + text = title, + color = titleColor, + fontSize = titleSize, + fontWeight = titleWeight, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } @Composable diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt index 2331498..126846c 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt @@ -78,39 +78,10 @@ object LumaMotion { val native = CubicBezierEasing(0.2f, 0.75f, 0.25f, 1f) } -/** Kyant Backdrop uses RenderEffect/GraphicsLayer and is unstable on some emulator GPUs. */ +/** Kyant Backdrop requires the Android 12 RenderEffect/graphics pipeline. */ internal object LumaGlassRuntime { val isBackdropSafe: Boolean by lazy { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return@lazy false - val fingerprint = Build.FINGERPRINT.lowercase() - val board = Build.BOARD.lowercase() - val hardware = Build.HARDWARE.lowercase() - val product = Build.PRODUCT.lowercase() - val device = Build.DEVICE.lowercase() - val model = Build.MODEL.lowercase() - val brand = Build.BRAND.lowercase() - val manufacturer = Build.MANUFACTURER.lowercase() - val isX86Emulator = Build.SUPPORTED_ABIS.any { it.contains("x86") } - !(isX86Emulator || - fingerprint.startsWith("generic") || - fingerprint.contains("emulator") || - board.contains("goldfish") || - board.contains("ranchu") || - board.contains("emulator") || - hardware.contains("goldfish") || - hardware.contains("ranchu") || - hardware.contains("vbox") || - product.contains("sdk_gphone") || - product.contains("emulator") || - device.contains("generic") || - device.contains("emulator") || - device.contains("emu") || - device.contains("vbox") || - model.contains("sdk_gphone") || - model.contains("emulator") || - model.contains("android sdk built for") || - brand == "generic" || - manufacturer.contains("genymotion")) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S } val useNativeBackdrop: Boolean @@ -167,7 +138,34 @@ fun Modifier.lumaFallbackGlass( neutralVeil: Color? = null, ): Modifier { val light = !LumaColors.isDark + val liquidFallback = AppTheme.glassMode == com.example.myapplication.ui.GlassMode.Liquid val fill = when { + liquidFallback && light && selected -> Brush.verticalGradient( + listOf( + Color.White.copy(alpha = 0.62f), + Color(0xFFE5EFFA).copy(alpha = 0.52f), + Color.White.copy(alpha = 0.42f), + ), + ) + liquidFallback && light -> Brush.verticalGradient( + listOf( + Color.White.copy(alpha = 0.48f), + Color(0xFFE4EDF7).copy(alpha = 0.40f), + Color.White.copy(alpha = 0.30f), + ), + ) + liquidFallback && selected -> Brush.verticalGradient( + listOf( + Color(0xFF60758A).copy(alpha = 0.70f), + Color(0xFF304253).copy(alpha = 0.64f), + ), + ) + liquidFallback -> Brush.verticalGradient( + listOf( + Color(0xFF44586C).copy(alpha = 0.60f), + Color(0xFF233140).copy(alpha = 0.66f), + ), + ) light && selected -> Brush.verticalGradient( listOf( Color.White.copy(alpha = 0.82f), @@ -196,14 +194,42 @@ fun Modifier.lumaFallbackGlass( ) } val edge = if (light) { - Color.White.copy(alpha = if (selected) 0.78f else 0.64f) + Color.White.copy( + alpha = when { + liquidFallback && selected -> 0.86f + liquidFallback -> 0.70f + selected -> 0.78f + else -> 0.64f + }, + ) } else { - Color.White.copy(alpha = if (selected) 0.28f else 0.20f) + Color.White.copy( + alpha = when { + liquidFallback && selected -> 0.34f + liquidFallback -> 0.24f + selected -> 0.28f + else -> 0.20f + }, + ) } val topHighlight = if (light) { - Color.White.copy(alpha = if (selected) 0.32f else 0.22f) + Color.White.copy( + alpha = when { + liquidFallback && selected -> 0.42f + liquidFallback -> 0.30f + selected -> 0.32f + else -> 0.22f + }, + ) } else { - Color.White.copy(alpha = if (selected) 0.10f else 0.06f) + Color.White.copy( + alpha = when { + liquidFallback && selected -> 0.14f + liquidFallback -> 0.08f + selected -> 0.10f + else -> 0.06f + }, + ) } return this .shadow( diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt index d36ba4a..3d088ec 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt @@ -29,19 +29,15 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastRoundToInt import androidx.compose.ui.util.lerp import com.kyant.backdrop.Backdrop -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberCombinedBackdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens @@ -54,7 +50,6 @@ import com.example.myapplication.ui.design.LumaColors import com.example.myapplication.ui.design.LumaGlassRuntime import com.example.myapplication.ui.design.LumaSpacing import com.example.myapplication.ui.design.lumaFallbackGlass -import com.example.myapplication.ui.design.rememberLumaLayerBackdrop import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch @@ -64,9 +59,7 @@ import kotlin.math.sign /** true = the hidden accent-capture layer, false = the visible base layer */ val LocalLiquidBottomTabsGhostLayer = staticCompositionLocalOf { false } -/** Current liquid pill position. Used to hide duplicated visible content beneath the pill. */ -val LocalLiquidBottomTabsPillValue = staticCompositionLocalOf { 0f } - +/** Local marker retained for the bottom-tab content layer. */ @Composable fun LiquidBottomTabs( selectedTabIndex: () -> Int, @@ -75,25 +68,35 @@ fun LiquidBottomTabs( tabsCount: Int, showSelection: () -> Boolean = { true }, modifier: Modifier = Modifier, - accentColor: Color = LumaColors.accent, content: @Composable RowScope.() -> Unit ) { val isLightTheme = !LumaColors.isDark val containerColor = LumaColors.clearTint val neutralTint = if (isLightTheme) { - Color(0xFFE1E6EC).copy(alpha = 0.62f) + Color(0xFFD4D9DF).copy(alpha = 0.78f) } else { Color(0xFF1C2430).copy(alpha = 0.54f) } val selectedNeutralTint = if (isLightTheme) { - Color(0xFFD1D7DF).copy(alpha = 0.54f) + Color(0xFFC1C8D0).copy(alpha = 0.82f) } else { Color(0xFF26313E).copy(alpha = 0.44f) } + val liquidFallback = com.example.myapplication.ui.AppTheme.glassMode == com.example.myapplication.ui.GlassMode.Liquid + val fallbackContainerVeil = if (isLightTheme) { + if (liquidFallback) Color(0xFFD7E3F0).copy(alpha = 0.54f) + else Color(0xFFD4D9DF).copy(alpha = 0.78f) + } else { + Color(0xFF1C2430).copy(alpha = 0.70f) + } + val fallbackSelectedVeil = if (isLightTheme) { + if (liquidFallback) Color(0xFFC9D8E8).copy(alpha = 0.60f) + else Color(0xFFC1C8D0).copy(alpha = 0.82f) + } else { + Color(0xFF26313E).copy(alpha = 0.62f) + } val useBackdrop = LumaGlassRuntime.useNativeBackdrop && backdrop != null - val tabsBackdrop = rememberLumaLayerBackdrop() - BoxWithConstraints( modifier, contentAlignment = Alignment.CenterStart @@ -186,11 +189,7 @@ fun LiquidBottomTabs( .fillMaxWidth() .lumaFallbackGlass( shape = Capsule(), - neutralVeil = if (isLightTheme) { - Color(0xFFE1E6EC).copy(alpha = 0.58f) - } else { - Color(0xFF1C2430).copy(alpha = 0.70f) - }, + neutralVeil = fallbackContainerVeil, ), ) if (showSelection()) { @@ -209,123 +208,45 @@ fun LiquidBottomTabs( .lumaFallbackGlass( shape = Capsule(), selected = true, - neutralVeil = if (isLightTheme) { - Color(0xFFD1D7DF).copy(alpha = 0.56f) - } else { - Color(0xFF26313E).copy(alpha = 0.62f) - }, + neutralVeil = fallbackSelectedVeil, ), ) } } - Row( - Modifier - .graphicsLayer { - translationX = panelOffset - } - .clip(Capsule()) - .then( - if (useBackdrop) { - Modifier.drawBackdrop( - backdrop = backdrop, - shape = { Capsule() }, - effects = { - vibrancy() - blur(8f.dp.toPx()) - lens(24f.dp.toPx(), 24f.dp.toPx()) - }, - layerBlock = { - val progress = dampedDragAnimation.pressProgress - val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress) - scaleX = scale - scaleY = scale - }, - onDrawSurface = { - drawRect(containerColor) - drawRect(neutralTint) - }, - ) - } else { - Modifier - }, - ) - .then( - if (useBackdrop) { - Modifier.border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule()) - } else { - Modifier - }, - ) - .then(interactiveHighlight.modifier) - .height(64f.dp) - .fillMaxWidth() - .padding(LumaSpacing.xxs) - .clip(Capsule()), - verticalAlignment = Alignment.CenterVertically, - content = { - CompositionLocalProvider( - LocalLiquidBottomTabsGhostLayer provides false, - LocalLiquidBottomTabsPillValue provides dampedDragAnimation.value, - ) { - content() - } - } - ) - - CompositionLocalProvider( - LocalLiquidBottomTabScale provides { - lerp(1f, 1.2f, dampedDragAnimation.pressProgress) - } - ) { - Row( + if (useBackdrop) { + Box( Modifier - .clearAndSetSemantics {} - .alpha(0f) + .graphicsLayer { translationX = panelOffset } .clip(Capsule()) - .then(if (useBackdrop && tabsBackdrop != null) Modifier.layerBackdrop(tabsBackdrop) else Modifier) - .graphicsLayer { - translationX = panelOffset - } - .then( - if (useBackdrop) { - Modifier.drawBackdrop( - backdrop = backdrop, - shape = { Capsule() }, - effects = { - val progress = dampedDragAnimation.pressProgress - vibrancy() - blur(8f.dp.toPx()) - lens( - 24f.dp.toPx() * progress, - 24f.dp.toPx() * progress, - ) - }, - highlight = { - val progress = dampedDragAnimation.pressProgress - Highlight.Plain.copy(alpha = progress) - }, - onDrawSurface = { drawRect(containerColor) }, - ) - } else { - Modifier + .drawBackdrop( + backdrop = backdrop, + shape = { Capsule() }, + effects = { + vibrancy() + blur(8f.dp.toPx()) + lens(24f.dp.toPx(), 24f.dp.toPx()) + }, + layerBlock = { + val progress = dampedDragAnimation.pressProgress + val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress) + scaleX = scale + scaleY = scale + }, + onDrawSurface = { + drawRect(containerColor) + drawRect(neutralTint) }, ) - .then(interactiveHighlight.modifier) - .height(56f.dp) + .border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule()) + .height(64f.dp) .fillMaxWidth() - .padding(horizontal = 4f.dp) - .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)), - verticalAlignment = Alignment.CenterVertically, - content = { - CompositionLocalProvider(LocalLiquidBottomTabsGhostLayer provides true) { - content() - } - } + .padding(LumaSpacing.xxs) + .clip(Capsule()), ) } - if (useBackdrop) { + if (useBackdrop && showSelection()) { Box( Modifier .padding(horizontal = 4f.dp) @@ -337,55 +258,74 @@ fun LiquidBottomTabs( } .then(interactiveHighlight.gestureModifier) .then(dampedDragAnimation.modifier) - .then( - Modifier.drawBackdrop( - backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop!!), - shape = { Capsule() }, - effects = { - lens( - 10f.dp.toPx(), - 14f.dp.toPx(), - chromaticAberration = true, - ) - }, - highlight = { - val progress = dampedDragAnimation.pressProgress - Highlight.Plain.copy(alpha = progress) - }, - shadow = { - val progress = dampedDragAnimation.pressProgress - Shadow(alpha = progress) - }, - innerShadow = { - val progress = dampedDragAnimation.pressProgress - InnerShadow( - radius = 8f.dp * progress, - alpha = progress, - ) - }, - layerBlock = { - scaleX = dampedDragAnimation.scaleX - scaleY = dampedDragAnimation.scaleY - val velocity = dampedDragAnimation.velocity / 10f - scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) - scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) - }, - onDrawSurface = { - val progress = dampedDragAnimation.pressProgress - drawRect(selectedNeutralTint) - drawRect( - if (isLightTheme) Color.Black.copy(0.1f) - else Color.White.copy(0.1f), - alpha = 1f - progress, - ) - drawRect(Color.Black.copy(alpha = 0.03f * progress)) - }, - ), + .drawBackdrop( + backdrop = backdrop, + shape = { Capsule() }, + effects = { + lens( + 10f.dp.toPx(), + 14f.dp.toPx(), + chromaticAberration = true, + ) + }, + highlight = { + val progress = dampedDragAnimation.pressProgress + Highlight.Plain.copy(alpha = progress) + }, + shadow = { + val progress = dampedDragAnimation.pressProgress + Shadow(alpha = progress) + }, + innerShadow = { + val progress = dampedDragAnimation.pressProgress + InnerShadow( + radius = 8f.dp * progress, + alpha = progress, + ) + }, + layerBlock = { + scaleX = dampedDragAnimation.scaleX + scaleY = dampedDragAnimation.scaleY + val velocity = dampedDragAnimation.velocity / 10f + scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) + scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) + }, + onDrawSurface = { + val progress = dampedDragAnimation.pressProgress + drawRect(selectedNeutralTint) + drawRect( + if (isLightTheme) Color.Black.copy(0.1f) + else Color.White.copy(0.1f), + alpha = 1f - progress, + ) + drawRect(Color.Black.copy(alpha = 0.03f * progress)) + }, ) .height(56f.dp) .fillMaxWidth(1f / tabsCount) - .alpha(if (showSelection()) 1f else 0f) + .alpha(1f), ) } + + Row( + Modifier + .graphicsLayer { + translationX = panelOffset + } + .clip(Capsule()) + .then(interactiveHighlight.modifier) + .height(64f.dp) + .fillMaxWidth() + .padding(LumaSpacing.xxs) + .clip(Capsule()), + verticalAlignment = Alignment.CenterVertically, + content = { + CompositionLocalProvider( + LocalLiquidBottomTabsGhostLayer provides false, + ) { + content() + } + } + ) } } diff --git a/memory/2026-08-28.md b/memory/2026-08-28.md index d215420..3a4316d 100644 --- a/memory/2026-08-28.md +++ b/memory/2026-08-28.md @@ -9,3 +9,9 @@ 文件变更: ui/design/LumaDesignSystem.kt、screens/HomeScreen.kt、navigation/AppNavHost.kt、screens/MediaDetailScreen.kt。 教训: 页面请求异常需保留取消异常;可保存滚动状态和 launchSingleTop 能减少返回闪烁与重复页面。 标签: #lumaplayer #navigation #state-restoration #fallback-ui + +[PROJECT:lumaplayer] 取消液态玻璃安全门 +结论: API 31+ 液态模式直接使用 Kyant Backdrop,底栏玻璃层级和媒体卡片三态完成收尾,API35 模拟器切换模式未复现 RenderThread 崩溃。 +文件变更: ui/design/LumaDesignSystem.kt、ui/liquidglass/LiquidBottomTabs.kt、ui/AppBottomBar.kt、ui/design/LumaComponents.kt、screens/HomeScreen.kt、screens/RecordsScreen.kt、screens/SearchScreen.kt。 +教训: 直接启用原生 Backdrop 后必须保持玻璃层与清晰内容分层;实体机 GPU 尚未验证。 +标签: #lumaplayer #liquid-glass #backdrop #fallback-ui #renderthread diff --git a/memory/MEMORY.md b/memory/MEMORY.md index 2449cbc..10c8ee1 100644 --- a/memory/MEMORY.md +++ b/memory/MEMORY.md @@ -3,10 +3,10 @@ - 项目:lumaplayer,Android Kotlin/Compose 媒体库客户端。 - 当前 Git 分支:`master`。 - 最近同步:`master` 已完成底栏图标与安全降级玻璃收尾,提交前工作区仅保留本地参考素材未跟踪。 -- 当前实现:玻璃模式改为设置页手动选择“液态玻璃/毛玻璃”,不再按帧率自动升降;液态模式在安全真机使用 Kyant0 Backdrop 2.0.1,模拟器/低版本仍在创建 Backdrop 前安全降级为渐变毛玻璃;页面捕获层仅服务根 Tab/底栏,子页玻璃无源时安全降级;添加服务器为独立普通表单页面并支持 HTTP/HTTPS;页面导航使用 Navigation Compose。 -- 最新稳定性策略:通过 `LumaGlassRuntime` 识别常见 Android Emulator GPU,模拟器跳过 Kyant RenderEffect/GraphicsLayer 采样,真机保留 Backdrop;播放器不捕获 AndroidView。 +- 当前实现:玻璃模式改为设置页手动选择“液态玻璃/毛玻璃”,不再按帧率自动升降;API 31+ 的液态模式直接使用 Kyant0 Backdrop 2.0.1,API 30 及以下降级为渐变毛玻璃;页面捕获层仅服务根 Tab/底栏,子页玻璃无源时安全降级;添加服务器为独立普通表单页面并支持 HTTP/HTTPS;页面导航使用 Navigation Compose。 +- 最新稳定性策略:按需求移除设备/模拟器安全门,只保留 Android 12+ API 能力判断;播放器不捕获 AndroidView。实体机 GPU 稳定性仍需单独验证。 - 当前 UI:新增 `ui/design` 令牌、公共加载/空状态/按钮、Aurora 图标子集和授权说明;主要页面已接入统一颜色、间距、圆角和动效。 -- 构建验证:Backdrop 层级收敛、模拟器安全降级、播放器采样隔离和 GlassSheet 收尾后 `assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功;已在 `sdk_gphone64_arm64` API 35 模拟器安装启动并点击 Vision/Settings/语言菜单,未出现 `SIGSEGV/RenderThread`,尚未做真机录屏比对。 +- 构建验证:Backdrop 层级收敛、播放器采样隔离和 GlassSheet 收尾后 `assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功;已在 `sdk_gphone64_arm64` API 35 模拟器安装启动并切换液态/毛玻璃,当前测试未出现 `SIGSEGV/RenderThread`,尚未做真机录屏比对。 - 详细状态见 [projects.md](projects.md),踩坑见 [lessons.md](lessons.md)。 - 最新收尾:公共分段控件、按钮、输入框、主题/语言/资源库弹层统一走同一套降级毛玻璃;底栏降级选中胶囊改为内容下层绘制;新增玻璃模式持久化设置;状态栏/导航栏图标随深色主题同步;Debug/Lint/Unit 与 API35 模拟器回归通过。 - 最新改动:播放器进度条改为自绘可访问 SeekBar,媒体首页、Vision 收藏和搜索加载态改为统一骨架占位;移除未使用的 Material 图标依赖,完整 Debug/Lint/Unit 通过。 diff --git a/memory/lessons.md b/memory/lessons.md index 5c0927b..9efbca9 100644 --- a/memory/lessons.md +++ b/memory/lessons.md @@ -20,3 +20,4 @@ - 高:液态玻璃不能以“API 版本高”作为唯一启用条件;Kyant Backdrop 还需通过设备安全门和运行帧率门控,低帧率/不安全 GPU 必须在创建 LayerBackdrop 前降级,避免 RenderThread 崩溃。 - 高:玻璃模式已改为用户手动选择,不能再用 Choreographer 帧率变化自动切换;但模拟器/低版本仍必须在创建 Kyant LayerBackdrop 前保留硬件安全门,避免 RenderThread SIGSEGV。 - 中:降级玻璃的边缘高光应在 `drawContent()` 之前绘制;否则公共玻璃容器中的文字和图标会被高光层洗白。 +- 高:用户明确要求真实液态玻璃时,不能继续保留设备/模拟器安全门;当前仅保留 API 31+ 能力判断,底栏原生 Backdrop 必须先绘制容器与选中胶囊,再绘制清晰图标/文字,避免内容被二次采样覆盖。该取舍需在实体机 GPU 上继续验证 RenderThread 稳定性。 diff --git a/memory/projects.md b/memory/projects.md index 47c4872..1bbf530 100644 --- a/memory/projects.md +++ b/memory/projects.md @@ -38,3 +38,4 @@ - 2026-08-27:底栏图标调整为 36dp、搜索图标 41dp,资源库无图标服务器使用正确的服务器图标;搜索清除与图标库返回补足 48dp 触控范围。完整 Debug/Lint/Unit 构建和 API35 模拟器回归通过。 - 2026-08-28:完整读取本地 `luna/README.txt`、`AI_PROMPT.txt` 与降级参考 contact sheet;修复降级底栏选中胶囊覆盖内容的问题,统一为稳定渐变毛玻璃;移除 Choreographer 帧率自动切换,设置页新增液态玻璃/毛玻璃手动选择并持久化;合并远端 3 个播放器提交后 `assembleDebug`、`lintDebug` 与 API35 模拟器启动回归通过。 - 2026-08-28:继续收尾降级 UI;修复 fallback 高光绘制顺序,首页接入可保存滚动状态,详情/播放器导航启用 `launchSingleTop` 防重复 Push,详情刷新和剧集加载异常进入已有错误态;`assembleDebug`、`lintDebug`、`testDebugUnitTest` 及 API35 模拟器启动回归成功。 +- 2026-08-28:按需求取消液态玻璃设备/模拟器安全门,API 31+ 液态模式直接进入 Kyant Backdrop 原生路径;底栏改为玻璃底层、选中胶囊、清晰内容的正确绘制顺序,主图标 40dp、搜索图标 44dp、文字 8sp;补充媒体卡片加载/成功/失败三态和统一按压反馈。`assembleDebug`、`lintDebug`、`testDebugUnitTest` 及 API35 模拟器液态/毛玻璃切换回归成功,未发现 RenderThread 崩溃。