取消液态玻璃安全门并收敛底栏绘制

This commit is contained in:
BoatingCloud committed 2026-08-28 10:37:49 +08:00
1 parent 485538f296
commit dafac3e509
11 files changed
+371 -277

No files matched your search

@@ -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,33 +388,19 @@ 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(),
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) },
)
}
}
Text(
text = item.seriesName ?: item.name,
color = homeContentColor(),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClick: (EmbyItem) -> Unit = {}) {
@@ -421,25 +408,27 @@ 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)) {
Box(
Modifier.fillMaxWidth().aspectRatio(16f / 9f)
.clip(RoundedCornerShape(LumaShapes.small))
.clickable { onItemClick(item) }
.background(Brush.linearGradient(fallbackColors)),
) {
if (imageUrl != null) {
LumaAsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(),
contentDescription = item.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
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.fillMaxSize().background(Color.Black.copy(alpha = 0.2f)),
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.20f)),
contentAlignment = Alignment.Center,
) {
LumaIconView(
@@ -450,15 +439,6 @@ private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClic
)
}
}
Text(
text = item.seriesName ?: item.name,
color = homeContentColor(),
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
@@ -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()
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,
)
}
}
Text(
text = item.name,
color = GlassTokens.text,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -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(),
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,
)
}
}
Text(
text = item.name,
color = GlassTokens.text,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -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,
)
}
@@ -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)
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,
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
@@ -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(
@@ -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,25 +208,18 @@ 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(
Box(
Modifier
.graphicsLayer { translationX = panelOffset }
.clip(Capsule())
.drawBackdrop(
backdrop = backdrop,
shape = { Capsule() },
effects = {
@@ -246,86 +238,15 @@ fun LiquidBottomTabs(
drawRect(neutralTint)
},
)
} else {
Modifier
},
)
.then(
if (useBackdrop) {
Modifier.border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule())
} else {
Modifier
},
)
.then(interactiveHighlight.modifier)
.border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule())
.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(
Modifier
.clearAndSetSemantics {}
.alpha(0f)
.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
},
)
.then(interactiveHighlight.modifier)
.height(56f.dp)
.fillMaxWidth()
.padding(horizontal = 4f.dp)
.graphicsLayer(colorFilter = ColorFilter.tint(accentColor)),
verticalAlignment = Alignment.CenterVertically,
content = {
CompositionLocalProvider(LocalLiquidBottomTabsGhostLayer provides true) {
content()
}
}
)
}
if (useBackdrop) {
if (useBackdrop && showSelection()) {
Box(
Modifier
.padding(horizontal = 4f.dp)
@@ -337,9 +258,8 @@ fun LiquidBottomTabs(
}
.then(interactiveHighlight.gestureModifier)
.then(dampedDragAnimation.modifier)
.then(
Modifier.drawBackdrop(
backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop!!),
.drawBackdrop(
backdrop = backdrop,
shape = { Capsule() },
effects = {
lens(
@@ -380,12 +300,32 @@ fun LiquidBottomTabs(
)
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()
}
}
)
}
}
}
+6
View File
@@ -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
+3 -3
View File
@@ -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 通过。
+1
View File
@@ -20,3 +20,4 @@
- 高:液态玻璃不能以“API 版本高”作为唯一启用条件;Kyant Backdrop 还需通过设备安全门和运行帧率门控,低帧率/不安全 GPU 必须在创建 LayerBackdrop 前降级,避免 RenderThread 崩溃。
- 高:玻璃模式已改为用户手动选择,不能再用 Choreographer 帧率变化自动切换;但模拟器/低版本仍必须在创建 Kyant LayerBackdrop 前保留硬件安全门,避免 RenderThread SIGSEGV。
- 中:降级玻璃的边缘高光应在 `drawContent()` 之前绘制;否则公共玻璃容器中的文字和图标会被高光层洗白。
- 高:用户明确要求真实液态玻璃时,不能继续保留设备/模拟器安全门;当前仅保留 API 31+ 能力判断,底栏原生 Backdrop 必须先绘制容器与选中胶囊,再绘制清晰图标/文字,避免内容被二次采样覆盖。该取舍需在实体机 GPU 上继续验证 RenderThread 稳定性。
+1
View File
@@ -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 崩溃。