完善降级玻璃界面并增加手动玻璃模式

This commit is contained in:
BoatingCloud committed 2026-08-28 09:23:52 +08:00
1 parent 156a6fe067
commit e76637c562
17 files changed
+351 -182

No files matched your search

@@ -9,7 +9,6 @@ import com.example.myapplication.data.IconLibraryRepository
import com.example.myapplication.data.ServerRepository
import com.example.myapplication.ui.AppLanguage
import com.example.myapplication.ui.AppTheme
import com.example.myapplication.ui.design.LumaGlassRuntime
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -25,7 +24,6 @@ class MainActivity : ComponentActivity() {
AppTheme.init(this)
IconLibraryRepository.init(this)
ServerRepository.init(this)
LumaGlassRuntime.startFrameMonitor()
dataReady = true
setContent {
@@ -33,8 +31,4 @@ class MainActivity : ComponentActivity() {
}
}
override fun onDestroy() {
LumaGlassRuntime.stopFrameMonitor()
super.onDestroy()
}
}
@@ -536,6 +536,7 @@ internal fun IconLibraryManagerScreenContent(
Box(Modifier.width(86.dp), contentAlignment = Alignment.CenterEnd) {
Row(
Modifier
.heightIn(min = 48.dp)
.lumaGlass(radius = 20.dp, level = com.example.myapplication.ui.design.LumaGlassLevel.Interactive)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { showAddDialog = true }
.padding(horizontal = 10.dp, vertical = 7.dp),
@@ -1310,12 +1311,22 @@ private fun IconPickerDialog(
}
if (query.text.isNotEmpty()) {
Spacer(Modifier.width(8.dp))
LumaIconView(
LumaIcon.Close,
contentDescription = stringResource(Res.string.icon_picker_clear_search),
tint = GlassTokens.subtext,
modifier = Modifier.size(18.dp).clickable { query = TextFieldValue("") },
)
Box(
Modifier
.size(48.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
) { query = TextFieldValue("") },
contentAlignment = Alignment.Center,
) {
LumaIconView(
LumaIcon.Close,
contentDescription = stringResource(Res.string.icon_picker_clear_search),
tint = GlassTokens.subtext,
modifier = Modifier.size(20.dp),
)
}
}
}
}
@@ -1607,15 +1618,22 @@ private fun ServerFormContent(
onValueChange = { password = it },
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingContent = {
LumaIconView(
icon = if (showPassword) LumaIcon.EyeOff else LumaIcon.Eye,
contentDescription = stringResource(if (showPassword) Res.string.server_hide_password else Res.string.server_show_password),
tint = GlassTokens.subtext,
modifier = Modifier.size(24.dp).clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { showPassword = !showPassword },
)
Box(
Modifier
.size(48.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { showPassword = !showPassword },
contentAlignment = Alignment.Center,
) {
LumaIconView(
icon = if (showPassword) LumaIcon.EyeOff else LumaIcon.Eye,
contentDescription = stringResource(if (showPassword) Res.string.server_hide_password else Res.string.server_show_password),
tint = GlassTokens.subtext,
modifier = Modifier.size(24.dp),
)
}
},
)
ServerFormInput(useGlass = useGlass, label = stringResource(Res.string.server_note_label), value = note, placeholder = stringResource(Res.string.server_note_placeholder), onValueChange = { note = it })
@@ -1707,7 +1725,7 @@ private fun PlainInputField(
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
Box(
Modifier.fillMaxWidth().clip(shape).background(fieldBackground)
Modifier.fillMaxWidth().heightIn(min = 48.dp).clip(shape).background(fieldBackground)
.border(0.8.dp, if (isError) GlassTokens.danger else LumaColors.glassBorder, shape)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
@@ -1766,14 +1784,28 @@ private fun ProtocolSwitch(
val shape = RoundedCornerShape(15.dp)
val trackColor = if (checked) GlassTokens.accent else LumaColors.backgroundElevated
Box(
Modifier.width(50.dp).height(30.dp).clip(shape).background(trackColor)
.border(0.8.dp, if (checked) GlassTokens.accent else GlassTokens.glassBorder, shape)
.toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange)
.padding(3.dp),
Modifier
.width(64.dp)
.height(48.dp)
.toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange),
contentAlignment = Alignment.Center,
) {
Box(
Modifier.size(24.dp).align(if (checked) Alignment.CenterEnd else Alignment.CenterStart)
.clip(CircleShape).background(Color.White),
)
Modifier
.width(50.dp)
.height(30.dp)
.clip(shape)
.background(trackColor)
.border(0.8.dp, if (checked) GlassTokens.accent else GlassTokens.glassBorder, shape)
.padding(3.dp),
) {
Box(
Modifier
.size(24.dp)
.align(if (checked) Alignment.CenterEnd else Alignment.CenterStart)
.clip(CircleShape)
.background(Color.White),
)
}
}
}
@@ -265,6 +265,7 @@ fun MediaDetailScreen(
val selected = season.id == selectedSeasonId
Box(
Modifier
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(999.dp))
.background(if (selected) LocalDetailAccent.current else LocalDetailSurface.current)
.border(1.dp, if (selected) Color.Transparent else LocalDetailBorder.current, RoundedCornerShape(999.dp))
@@ -60,6 +60,7 @@ import com.example.myapplication.R
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.ui.AppLanguage
import com.example.myapplication.ui.AppTheme
import com.example.myapplication.ui.GlassMode
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LiquidGlassTopBar
import com.example.myapplication.ui.LiquidGlassPopupOverlay
@@ -111,6 +112,9 @@ fun SettingsScreen(
var themeMenuVisible by remember { mutableStateOf(false) }
var themeMenuMounted by remember { mutableStateOf(false) }
var themeEntryBounds by remember { mutableStateOf<Rect?>(null) }
var glassModeMenuVisible by remember { mutableStateOf(false) }
var glassModeMenuMounted by remember { mutableStateOf(false) }
var glassModeEntryBounds by remember { mutableStateOf<Rect?>(null) }
var settingsRootOrigin by remember { mutableStateOf(Offset.Zero) }
val context = LocalContext.current
val languageScope = rememberCoroutineScope()
@@ -149,6 +153,17 @@ fun SettingsScreen(
},
)
DividerLine()
SettingRowItem(
icon = LumaIcon.Eye,
title = stringResource(R.string.settings_glass_mode),
subtitle = glassModeLabel(),
onTrailingIconPositioned = { glassModeEntryBounds = it.boundsInRoot() },
onClick = {
glassModeMenuMounted = true
glassModeMenuVisible = true
},
)
DividerLine()
SettingRowItem(
icon = LumaIcon.Globe,
title = stringResource(R.string.settings_language),
@@ -218,6 +233,21 @@ fun SettingsScreen(
},
)
}
val glassModeAnchor = glassModeEntryBounds
if (glassModeMenuMounted && glassModeAnchor != null) {
GlassModeMenuOverlay(
anchorBounds = glassModeAnchor,
rootOrigin = settingsRootOrigin,
backdrop = languageBackdrop,
visible = glassModeMenuVisible,
onDismiss = { glassModeMenuVisible = false },
onClosed = { glassModeMenuMounted = false },
onGlassModeSelected = { mode ->
glassModeMenuVisible = false
AppTheme.setGlassMode(context, mode)
},
)
}
}
}
}
@@ -303,6 +333,12 @@ private fun themeModeLabel(): String = when (AppTheme.themeMode) {
else -> stringResource(R.string.theme_light)
}
@Composable
private fun glassModeLabel(): String = when (AppTheme.glassMode) {
GlassMode.Liquid -> stringResource(R.string.glass_mode_liquid)
GlassMode.Frosted -> stringResource(R.string.glass_mode_frosted)
}
@Composable
private fun ThemeMenuOverlay(
anchorBounds: Rect,
@@ -369,6 +405,41 @@ private fun ThemeOption(
}
}
@Composable
private fun GlassModeMenuOverlay(
anchorBounds: Rect,
rootOrigin: Offset,
backdrop: Backdrop?,
visible: Boolean,
onDismiss: () -> Unit,
onClosed: () -> Unit,
onGlassModeSelected: (GlassMode) -> Unit,
) {
LiquidGlassPopupOverlay(
anchorBounds = anchorBounds,
rootOrigin = rootOrigin,
backdrop = backdrop,
visible = visible,
onDismissRequest = onDismiss,
onClosed = onClosed,
width = 190.dp,
height = 128.dp,
) {
ThemeOption(
icon = LumaIcon.Star,
label = stringResource(R.string.glass_mode_liquid),
selected = AppTheme.glassMode == GlassMode.Liquid,
onClick = { onGlassModeSelected(GlassMode.Liquid) },
)
ThemeOption(
icon = LumaIcon.Cloud,
label = stringResource(R.string.glass_mode_frosted),
selected = AppTheme.glassMode == GlassMode.Frosted,
onClick = { onGlassModeSelected(GlassMode.Frosted) },
)
}
}
@Composable
private fun LanguageMenuOverlay(
anchorBounds: Rect,
@@ -508,7 +579,11 @@ private fun LanguageOption(
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(18.dp))
.clickable(onClick = onClick)
.clickable(
indication = null,
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
onClick = onClick,
)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -762,6 +762,7 @@ private fun LiquidSurfaceButton(
onClick: () -> Unit,
) {
val bd = LocalGlassBackdrop.current
val touchSize = size.coerceAtLeast(48.dp)
val surfaceColor = if (active) Color.White.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.1f)
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
@@ -773,11 +774,11 @@ private fun LiquidSurfaceButton(
Box(
Modifier
.size(size)
.size(touchSize)
.graphicsLayer { scaleX = scale; scaleY = scale }
.lumaGlass(
backdrop = bd,
radius = size / 2f,
radius = touchSize / 2f,
level = LumaGlassLevel.Interactive,
tint = surfaceColor,
borderColor = Color.White.copy(alpha = 0.3f),
@@ -799,6 +800,7 @@ private fun LiquidSurfaceButton(
onClick: () -> Unit,
) {
val bd = LocalGlassBackdrop.current
val touchSize = size.coerceAtLeast(48.dp)
val surfaceColor = if (active) Color.White.copy(alpha = 0.26f) else Color.White.copy(alpha = 0.09f)
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
@@ -809,11 +811,11 @@ private fun LiquidSurfaceButton(
)
Box(
Modifier
.size(size)
.size(touchSize)
.graphicsLayer { scaleX = scale; scaleY = scale }
.lumaGlass(
backdrop = bd,
radius = size / 2f,
radius = touchSize / 2f,
level = LumaGlassLevel.Interactive,
tint = surfaceColor,
borderColor = Color.White.copy(alpha = 0.22f),
@@ -74,7 +74,7 @@ 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(36.dp),
modifier = Modifier.size(34.dp),
)
Spacer(Modifier.height(3.dp))
Text(
@@ -108,13 +108,28 @@ fun LiquidAppBottomBar(
lens(12f.dp.toPx(), 16f.dp.toPx())
},
onDrawSurface = {
drawRect(
if (LumaColors.isDark) {
Color(0xFF1C2430).copy(alpha = 0.54f)
} else {
Color(0xFFE1E6EC).copy(alpha = 0.62f)
},
)
if (searchSelected) {
drawRect(LumaColors.glassHighlight.copy(alpha = 0.40f))
}
},
)
} else {
Modifier.lumaFallbackGlass(CircleShape, selected = searchSelected)
Modifier.lumaFallbackGlass(
shape = CircleShape,
selected = searchSelected,
neutralVeil = if (LumaColors.isDark) {
Color(0xFF1C2430).copy(alpha = 0.72f)
} else {
Color(0xFFE1E6EC).copy(alpha = 0.82f)
},
)
},
)
.then(
@@ -136,7 +151,7 @@ fun LiquidAppBottomBar(
icon = LumaIcon.Search,
contentDescription = null,
tint = if (searchSelected) LumaColors.accent else inactive,
modifier = Modifier.size(41.dp),
modifier = Modifier.size(36.dp),
)
Text(
text = stringResource(Res.string.search),
@@ -6,13 +6,20 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
enum class GlassMode {
Liquid,
Frosted,
}
object AppTheme {
private const val preferencesName = "app_settings"
private const val themeKey = "theme"
private const val glassModeKey = "glass_mode"
// 0 = Light, 1 = Dark, 2 = Follow System
var themeMode by mutableIntStateOf(0)
var systemIsDark by mutableStateOf(false)
var glassMode by mutableStateOf(GlassMode.Liquid)
val isDark: Boolean
get() = when (themeMode) {
@@ -22,10 +29,12 @@ object AppTheme {
}
fun init(context: Context) {
themeMode = context
.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
.getInt(themeKey, 0)
.coerceIn(0, 2)
val preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
themeMode = preferences.getInt(themeKey, 0).coerceIn(0, 2)
glassMode = when (preferences.getInt(glassModeKey, GlassMode.Liquid.ordinal)) {
GlassMode.Frosted.ordinal -> GlassMode.Frosted
else -> GlassMode.Liquid
}
}
fun setMode(context: Context, mode: Int) {
@@ -36,4 +45,13 @@ object AppTheme {
.putInt(themeKey, themeMode)
.apply()
}
fun setGlassMode(context: Context, mode: GlassMode) {
glassMode = mode
context
.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
.edit()
.putInt(glassModeKey, mode.ordinal)
.apply()
}
}
@@ -9,6 +9,7 @@ import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.colorControls
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow
@@ -253,6 +254,7 @@ fun SegmentedControl(
Box(
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(16.dp))
.then(
if (sel && backdrop != null && LumaGlassRuntime.useNativeBackdrop) {
@@ -468,6 +470,11 @@ fun LiquidGlassPopupOverlay(
)
vibrancy()
blur(if (GlassTokens.isDark) 8.dp.toPx() else 16.dp.toPx())
lens(
refractionHeight = 8.dp.toPx() * rawProgress,
refractionAmount = 18.dp.toPx() * rawProgress,
depthEffect = true,
)
},
highlight = { Highlight.Plain },
shadow = { Shadow(alpha = 0.18f) },
@@ -534,6 +541,7 @@ fun LiquidGlassButton(
)
Row(
modifier
.heightIn(min = 48.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
@@ -709,6 +717,7 @@ fun GlassInputField(
val shape = RoundedCornerShape(12.dp)
Box(
Modifier.fillMaxWidth()
.heightIn(min = 48.dp)
.lumaGlass(
backdrop = backdrop,
radius = 12.dp,
@@ -1,14 +1,12 @@
package com.example.myapplication.ui.design
import android.os.Build
import android.view.Choreographer
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -16,7 +14,6 @@ import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
@@ -31,6 +28,7 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.colorControls
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow
@@ -115,50 +113,8 @@ internal object LumaGlassRuntime {
manufacturer.contains("genymotion"))
}
private val frameRateState = mutableFloatStateOf(0f)
private var measuredSamples = 0
private var frameCallback: Choreographer.FrameCallback? = null
private var sampleStartNanos = 0L
private var frameCount = 0
val useNativeBackdrop: Boolean
get() = isBackdropSafe && measuredSamples > 0 && frameRateState.floatValue >= 48f
fun startFrameMonitor() {
if (frameCallback != null) return
sampleStartNanos = 0L
frameCount = 0
measuredSamples = 0
frameRateState.floatValue = 0f
val callback = object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (sampleStartNanos == 0L) {
sampleStartNanos = frameTimeNanos
} else {
frameCount++
val elapsedNanos = frameTimeNanos - sampleStartNanos
if (elapsedNanos >= 500_000_000L) {
frameRateState.floatValue =
(frameCount * 1_000_000_000f / elapsedNanos.toFloat()).coerceIn(1f, 120f)
measuredSamples++
sampleStartNanos = frameTimeNanos
frameCount = 0
}
}
Choreographer.getInstance().postFrameCallback(this)
}
}
frameCallback = callback
Choreographer.getInstance().postFrameCallback(callback)
}
fun stopFrameMonitor() {
frameCallback?.let(Choreographer.getInstance()::removeFrameCallback)
frameCallback = null
measuredSamples = 0
frameRateState.floatValue = 0f
}
get() = AppTheme.glassMode == com.example.myapplication.ui.GlassMode.Liquid && isBackdropSafe
}
@Composable
@@ -208,83 +164,70 @@ fun Modifier.lumaFallbackGlass(
shape: Shape,
selected: Boolean = false,
border: Boolean = true,
neutralVeil: Color? = null,
): Modifier {
val light = !LumaColors.isDark
val top = if (selected) {
if (light) Color.White.copy(alpha = 0.34f) else Color.White.copy(alpha = 0.15f)
} else {
if (light) Color.White.copy(alpha = 0.24f) else Color.White.copy(alpha = 0.08f)
val fill = when {
light && selected -> Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.82f),
Color(0xFFE6E9ED).copy(alpha = 0.76f),
Color.White.copy(alpha = 0.64f),
),
)
light -> Brush.verticalGradient(
listOf(
Color.White.copy(alpha = 0.72f),
Color(0xFFE6E9ED).copy(alpha = 0.66f),
Color.White.copy(alpha = 0.56f),
),
)
selected -> Brush.verticalGradient(
listOf(
Color(0xFF526171).copy(alpha = 0.86f),
Color(0xFF273340).copy(alpha = 0.84f),
),
)
else -> Brush.verticalGradient(
listOf(
Color(0xFF3A4654).copy(alpha = 0.78f),
Color(0xFF1D2733).copy(alpha = 0.82f),
),
)
}
val bottom = if (light) {
Color(0xFFB8C5D5).copy(alpha = 0.14f)
val edge = if (light) {
Color.White.copy(alpha = if (selected) 0.78f else 0.64f)
} else {
Color(0xFF121B27).copy(alpha = 0.46f)
Color.White.copy(alpha = if (selected) 0.28f else 0.20f)
}
val edge = if (selected) {
if (light) {
Color(0xFF9BA8B8).copy(alpha = 0.28f)
} else {
Color.White.copy(alpha = 0.22f)
}
val topHighlight = if (light) {
Color.White.copy(alpha = if (selected) 0.32f else 0.22f)
} else {
LumaColors.glassHighlight.copy(alpha = if (light) 0.18f else 0.14f)
}
val fill = if (selected) {
if (light) {
Brush.horizontalGradient(
listOf(
Color.White.copy(alpha = 0.44f),
Color.White.copy(alpha = 0.66f),
Color(0xFFDCE3EB).copy(alpha = 0.48f),
Color.White.copy(alpha = 0.38f),
),
)
} else {
Brush.horizontalGradient(
listOf(
Color.White.copy(alpha = 0.16f),
Color(0xFF46515F).copy(alpha = 0.30f),
Color.White.copy(alpha = 0.20f),
Color(0xFF202A36).copy(alpha = 0.28f),
),
)
}
} else {
Brush.verticalGradient(listOf(top, bottom))
Color.White.copy(alpha = if (selected) 0.10f else 0.06f)
}
return this
.shadow(
elevation = if (selected) 2.dp else 4.dp,
shape = shape,
ambientColor = LumaColors.glassShadow,
spotColor = LumaColors.glassShadow,
)
.clip(shape)
.background(fill, shape)
.then(
if (selected) {
Modifier.drawWithCache {
val leftGlow = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = if (light) 0.26f else 0.18f),
Color.White.copy(alpha = if (light) 0.08f else 0.05f),
Color.Transparent,
),
center = Offset(size.width * 0.28f, size.height * 0.18f),
radius = size.height * 1.25f,
)
val rightGlow = Brush.radialGradient(
colors = listOf(
Color.White.copy(alpha = if (light) 0.34f else 0.18f),
Color.Transparent,
),
center = Offset(size.width * 0.86f, size.height * 0.22f),
radius = size.height * 0.92f,
)
onDrawWithContent {
drawCircle(leftGlow, center = Offset(size.width * 0.28f, size.height * 0.18f), radius = size.height * 1.25f)
drawCircle(rightGlow, center = Offset(size.width * 0.86f, size.height * 0.22f), radius = size.height * 0.92f)
drawContent()
}
}
} else {
Modifier
},
neutralVeil?.let { Modifier.background(it, shape) } ?: Modifier,
)
.drawWithCache {
val highlight = Brush.verticalGradient(
listOf(topHighlight, Color.Transparent),
startY = 0f,
endY = size.height * 0.72f,
)
onDrawWithContent {
drawContent()
drawRect(highlight)
}
}
.then(if (border) Modifier.border(0.6.dp, edge, shape) else Modifier)
.clip(shape)
}
@@ -350,6 +293,13 @@ fun Modifier.lumaGlass(
)
vibrancy()
blur(spec.blur.toPx())
if (level == LumaGlassLevel.Floating || level == LumaGlassLevel.Interactive) {
lens(
refractionHeight = if (level == LumaGlassLevel.Interactive) 8.dp.toPx() else 6.dp.toPx(),
refractionAmount = if (level == LumaGlassLevel.Interactive) 18.dp.toPx() else 12.dp.toPx(),
depthEffect = true,
)
}
},
highlight = if (highlight) ({ Highlight.Plain.copy(alpha = 0.75f) }) else null,
shadow = { Shadow(alpha = spec.shadowAlpha) },
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.border
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -79,6 +80,16 @@ fun LiquidBottomTabs(
) {
val isLightTheme = !LumaColors.isDark
val containerColor = LumaColors.clearTint
val neutralTint = if (isLightTheme) {
Color(0xFFE1E6EC).copy(alpha = 0.62f)
} else {
Color(0xFF1C2430).copy(alpha = 0.54f)
}
val selectedNeutralTint = if (isLightTheme) {
Color(0xFFD1D7DF).copy(alpha = 0.54f)
} else {
Color(0xFF26313E).copy(alpha = 0.44f)
}
val useBackdrop = LumaGlassRuntime.useNativeBackdrop && backdrop != null
val tabsBackdrop = rememberLumaLayerBackdrop()
@@ -167,6 +178,47 @@ fun LiquidBottomTabs(
)
}
if (!useBackdrop) {
Box(
Modifier
.graphicsLayer { translationX = panelOffset }
.height(64.dp)
.fillMaxWidth()
.lumaFallbackGlass(
shape = Capsule(),
neutralVeil = if (isLightTheme) {
Color(0xFFE1E6EC).copy(alpha = 0.58f)
} else {
Color(0xFF1C2430).copy(alpha = 0.70f)
},
),
)
if (showSelection()) {
Box(
Modifier
.padding(horizontal = 4.dp)
.graphicsLayer {
translationX =
if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset
else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset
}
.then(interactiveHighlight.gestureModifier)
.then(dampedDragAnimation.modifier)
.height(56.dp)
.width(with(density) { tabWidth.toDp() })
.lumaFallbackGlass(
shape = Capsule(),
selected = true,
neutralVeil = if (isLightTheme) {
Color(0xFFD1D7DF).copy(alpha = 0.56f)
} else {
Color(0xFF26313E).copy(alpha = 0.62f)
},
),
)
}
}
Row(
Modifier
.graphicsLayer {
@@ -189,10 +241,13 @@ fun LiquidBottomTabs(
scaleX = scale
scaleY = scale
},
onDrawSurface = { drawRect(containerColor) },
onDrawSurface = {
drawRect(containerColor)
drawRect(neutralTint)
},
)
} else {
Modifier.lumaFallbackGlass(Capsule())
Modifier
},
)
.then(
@@ -270,19 +325,19 @@ fun LiquidBottomTabs(
)
}
Box(
Modifier
.padding(horizontal = 4f.dp)
.clip(Capsule())
.graphicsLayer {
translationX =
if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset
else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset
}
.then(interactiveHighlight.gestureModifier)
.then(dampedDragAnimation.modifier)
.then(
if (useBackdrop) {
if (useBackdrop) {
Box(
Modifier
.padding(horizontal = 4f.dp)
.clip(Capsule())
.graphicsLayer {
translationX =
if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset
else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset
}
.then(interactiveHighlight.gestureModifier)
.then(dampedDragAnimation.modifier)
.then(
Modifier.drawBackdrop(
backdrop = rememberCombinedBackdrop(backdrop, tabsBackdrop!!),
shape = { Capsule() },
@@ -317,6 +372,7 @@ fun LiquidBottomTabs(
},
onDrawSurface = {
val progress = dampedDragAnimation.pressProgress
drawRect(selectedNeutralTint)
drawRect(
if (isLightTheme) Color.Black.copy(0.1f)
else Color.White.copy(0.1f),
@@ -324,14 +380,12 @@ fun LiquidBottomTabs(
)
drawRect(Color.Black.copy(alpha = 0.03f * progress))
},
)
} else {
Modifier.lumaFallbackGlass(Capsule(), selected = true)
},
)
.height(56f.dp)
.fillMaxWidth(1f / tabsCount)
.alpha(if (showSelection()) 1f else 0f)
)
),
)
.height(56f.dp)
.fillMaxWidth(1f / tabsCount)
.alpha(if (showSelection()) 1f else 0f)
)
}
}
}
@@ -5,9 +5,15 @@
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData=" M50 17 C54 17 56 20 57 24 C61 25 64 27 67 29 C70 27 74 27 77 30 C80 33 80 37 78 40 C80 43 81 46 81 50 C85 52 87 55 87 59 C87 63 84 66 80 67 C78 70 76 73 73 75 C74 79 73 83 70 85 C67 88 63 88 60 86 C56 88 53 89 50 89 C48 93 44 95 40 94 C36 93 34 90 33 86 C29 85 26 83 23 80 C19 81 15 80 13 77 C10 74 10 70 12 67 C11 63 10 60 10 56 C6 54 4 50 5 46 C6 42 9 39 13 38 C15 34 17 31 20 29 C19 25 20 21 23 19 C26 16 30 16 33 18 C37 16 40 15 44 15 C45 11 47 17 50 17 Z "
android:fillColor="#0A84FF" />
android:pathData="M50 17 C54 17 56 20 57 24 C61 25 64 27 67 29 C70 27 74 27 77 30 C80 33 80 37 78 40 C80 43 81 46 81 50 C85 52 87 55 87 59 C87 63 84 66 80 67 C78 70 76 73 73 75 C74 79 73 83 70 85 C67 88 63 88 60 86 C56 88 53 89 50 89 C48 93 44 95 40 94 C36 93 34 90 33 86 C29 85 26 83 23 80 C19 81 15 80 13 77 C10 74 10 70 12 67 C11 63 10 60 10 56 C6 54 4 50 5 46 C6 42 9 39 13 38 C15 34 17 31 20 29 C19 25 20 21 23 19 C26 16 30 16 33 18 C37 16 40 15 44 15 C45 11 47 17 50 17 Z"
android:fillColor="@android:color/transparent"
android:strokeColor="#0A84FF"
android:strokeWidth="5.8"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<path
android:pathData="M38.5,53.0 A11.5,11.5 0 1 0 61.5,53.0 A11.5,11.5 0 1 0 38.5,53.0 Z"
android:fillColor="#FFFFFF" />
</vector>
android:fillColor="@android:color/transparent"
android:strokeColor="#0A84FF"
android:strokeWidth="6.0" />
</vector>
+3
View File
@@ -6,6 +6,9 @@
<string name="nav_settings">Settings</string>
<string name="settings_title">Settings</string>
<string name="settings_theme">Theme</string>
<string name="settings_glass_mode">Glass mode</string>
<string name="glass_mode_liquid">Liquid glass</string>
<string name="glass_mode_frosted">Frosted glass</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="theme_system">System</string>
+3
View File
@@ -7,6 +7,9 @@
<string name="nav_settings">设置</string>
<string name="settings_title">设置</string>
<string name="settings_theme">主题</string>
<string name="settings_glass_mode">玻璃模式</string>
<string name="glass_mode_liquid">液态玻璃</string>
<string name="glass_mode_frosted">毛玻璃</string>
<string name="theme_light">浅色</string>
<string name="theme_dark">深色</string>
<string name="theme_system">系统</string>
+5
View File
@@ -0,0 +1,5 @@
[PROJECT:lumaplayer] 完成降级 UI 与手动玻璃模式
结论: 按 luna 降级参考修正底栏绘制层级与磨砂材质,移除帧率自动升降,设置页新增液态玻璃/毛玻璃手动模式并持久化。
文件变更: MainActivity.kt、AppTheme.kt、LumaDesignSystem.kt、LiquidBottomTabs.kt、AppBottomBar.kt、SettingsScreen.kt、strings.xml、values-en/strings.xml 及既有 UI 收尾文件。
教训: 远端有新提交时先暂存跟踪文件再合并;模式手动选择与模拟器安全硬门需同时保留。
标签: #lumaplayer #fallback-ui #glass-mode #renderthread #remote-merge
+2 -2
View File
@@ -3,10 +3,10 @@
- 项目:lumaplayer,Android Kotlin/Compose 媒体库客户端。
- 当前 Git 分支:`master`。
- 最近同步:`master` 已完成底栏图标与安全降级玻璃收尾,提交前工作区仅保留本地参考素材未跟踪。
- 当前实现:底栏与安全的 Glass 组件使用 Kyant0 Backdrop 2.0.1;安全且实测帧率不低于 48fps 时使用 Backdrop + blur/vibrancy/lens,模拟器、低帧率或低版本使用渐变毛玻璃降级;页面捕获层仅服务根 Tab/底栏,子页玻璃无源时安全降级;添加服务器为独立普通表单页面并支持 HTTP/HTTPS;页面导航使用 Navigation Compose。
- 当前实现:玻璃模式改为设置页手动选择“液态玻璃/毛玻璃”,不再按帧率自动升降;液态模式在安全真机使用 Kyant0 Backdrop 2.0.1,模拟器/低版本仍在创建 Backdrop 前安全降级为渐变毛玻璃;页面捕获层仅服务根 Tab/底栏,子页玻璃无源时安全降级;添加服务器为独立普通表单页面并支持 HTTP/HTTPS;页面导航使用 Navigation Compose。
- 最新稳定性策略:通过 `LumaGlassRuntime` 识别常见 Android Emulator GPU,模拟器跳过 Kyant RenderEffect/GraphicsLayer 采样,真机保留 Backdrop;播放器不捕获 AndroidView。
- 当前 UI:新增 `ui/design` 令牌、公共加载/空状态/按钮、Aurora 图标子集和授权说明;主要页面已接入统一颜色、间距、圆角和动效。
- 构建验证:Backdrop 层级收敛、模拟器安全降级、播放器采样隔离和 GlassSheet 收尾后 `assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功;已在 `sdk_gphone64_arm64` API 35 模拟器安装启动并点击 Vision/Settings/语言菜单,未出现 `SIGSEGV/RenderThread`,尚未做真机录屏比对。
- 详细状态见 [projects.md](projects.md),踩坑见 [lessons.md](lessons.md)。
- 最新收尾:公共分段控件、按钮、输入框、主题/语言/资源库弹层统一走同一套降级毛玻璃;状态栏/导航栏图标随深色主题同步;Debug/Lint/Unit 与 API35 模拟器回归通过。
- 最新收尾:公共分段控件、按钮、输入框、主题/语言/资源库弹层统一走同一套降级毛玻璃;底栏降级选中胶囊改为内容下层绘制;新增玻璃模式持久化设置;状态栏/导航栏图标随深色主题同步;Debug/Lint/Unit 与 API35 模拟器回归通过。
- 最新改动:播放器进度条改为自绘可访问 SeekBar,媒体首页、Vision 收藏和搜索加载态改为统一骨架占位;移除未使用的 Material 图标依赖,完整 Debug/Lint/Unit 通过。
+1
View File
@@ -18,3 +18,4 @@
- 高:模拟器不一定使用标准 `sdk_gphone` 指纹,RenderThread 防护还应检查 x86 ABI、board 和 `emu` 设备名;详情页绘制回调中的 Compose 状态也必须提前快照。
- 中:Emby 播放回报必须沿用 PlaybackInfo 返回的 `PlaySessionId`/`MediaSourceId`,并将开始、进度、停止事件串行化;把回报协程绑定到播放器 Composable 生命周期会在返回页面时丢失停止事件。
- 高:液态玻璃不能以“API 版本高”作为唯一启用条件;Kyant Backdrop 还需通过设备安全门和运行帧率门控,低帧率/不安全 GPU 必须在创建 LayerBackdrop 前降级,避免 RenderThread 崩溃。
- 高:玻璃模式已改为用户手动选择,不能再用 Choreographer 帧率变化自动切换;但模拟器/低版本仍必须在创建 Kyant LayerBackdrop 前保留硬件安全门,避免 RenderThread SIGSEGV。
+1
View File
@@ -36,3 +36,4 @@
- 2026-08-27:收敛公共分段控件、弹出菜单、按钮、输入框及语言菜单的降级玻璃路径,移除纯色方框;补充深色主题系统栏图标同步和操作标题 48dp 点击区域。`assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功,API 35 模拟器验证设置/语言/主题/关于/服务器添加链路无崩溃;本地参考素材仍未跟踪。
- 2026-08-27:播放器进度条替换为自绘拖拽控件并补充无障碍进度调节,Home/Vision/Search 的媒体加载态改为统一骨架占位;移除未使用的 Material 图标依赖。`assembleDebug`、`lintDebug`、`testDebugUnitTest` 成功。
- 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 模拟器启动回归通过。