服务器列表优化

This commit is contained in:
beyond committed 2026-08-30 02:18:24 +08:00
1 parent bbbebe38eb
commit e33dcf533c
18 files changed
+877 -545

No files matched your search

@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase;
@Database(
entities = {IconLibraryEntity.class, ServerEntity.class},
version = 6,
version = 7,
exportSchema = false
)
public abstract class AppDatabase extends RoomDatabase {
@@ -83,6 +83,13 @@ public abstract class AppDatabase extends RoomDatabase {
}
};
private static final Migration MIGRATION_6_7 = new Migration(6, 7) {
@Override
public void migrate(SupportSQLiteDatabase db) {
db.execSQL("ALTER TABLE servers ADD COLUMN alternateAddresses TEXT");
}
};
public static AppDatabase getInstance(Context context) {
if (instance == null) {
synchronized (AppDatabase.class) {
@@ -91,7 +98,7 @@ public static AppDatabase getInstance(Context context) {
context.getApplicationContext(),
AppDatabase.class,
"emby_player.db"
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6).build();
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7).build();
}
}
}
@@ -22,6 +22,7 @@ public class ServerEntity {
public String userId;
public String iconUrl;
public int isSelected;
public String alternateAddresses;
@Ignore
public ServerEntity(
@@ -36,7 +37,7 @@ public class ServerEntity {
String accessToken,
String userId
) {
this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, "", 0);
this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, "", 0, "[]");
}
@Ignore
@@ -53,7 +54,7 @@ public class ServerEntity {
String userId,
String iconUrl
) {
this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, iconUrl, 0);
this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, iconUrl, 0, "[]");
}
@Ignore
@@ -71,7 +72,7 @@ public class ServerEntity {
String iconUrl,
int isSelected
) {
this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, iconUrl, isSelected);
this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, iconUrl, isSelected, "[]");
}
public ServerEntity(
@@ -87,7 +88,8 @@ public class ServerEntity {
String accessToken,
String userId,
String iconUrl,
int isSelected
int isSelected,
String alternateAddresses
) {
this.id = id;
this.name = name;
@@ -102,5 +104,6 @@ public class ServerEntity {
this.userId = userId;
this.iconUrl = iconUrl == null ? "" : iconUrl;
this.isSelected = isSelected;
this.alternateAddresses = alternateAddresses == null ? "[]" : alternateAddresses;
}
}
@@ -2,7 +2,9 @@ package com.example.myapplication
import android.app.Activity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
@@ -113,6 +115,14 @@ fun MainApp() {
}
val selectedTab = rootTab ?: lastRootTab
val showBottomBar = isRootRoute(currentRoute)
val isSettingsChild = currentRoute == AppRoute.CacheManagement.pattern ||
currentRoute == AppRoute.About.pattern
var lastDetailRoute by remember { mutableStateOf<String?>(null) }
LaunchedEffect(currentRoute, showBottomBar) {
if (!showBottomBar) lastDetailRoute = currentRoute
}
val returningFromSettingsChild = showBottomBar &&
(lastDetailRoute == AppRoute.CacheManagement.pattern || lastDetailRoute == AppRoute.About.pattern)
val glassBackdrop = rememberLumaLayerBackdrop()
val loginFailedMessage = stringResource(Res.string.error_login_failed)
val networkErrorMessage = stringResource(
@@ -223,8 +233,16 @@ fun MainApp() {
AnimatedVisibility(
visible = showBottomBar,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn(tween(180)) + slideInVertically(tween(180)) { it / 3 },
exit = fadeOut(tween(180)) + slideOutVertically(tween(180)) { it / 3 },
enter = if (returningFromSettingsChild) {
EnterTransition.None
} else {
fadeIn(tween(180)) + slideInVertically(tween(180)) { it / 3 }
},
exit = if (isSettingsChild) {
ExitTransition.None
} else {
fadeOut(tween(180)) + slideOutVertically(tween(180)) { it / 3 }
},
) {
Box(
Modifier.fillMaxWidth().padding(bottom = 12.dp),
@@ -2,6 +2,7 @@ package com.example.myapplication.data
import androidx.annotation.StringRes
import com.example.myapplication.R
import java.net.URI
import java.util.UUID
data class EmbyLibrary(
@@ -39,6 +40,7 @@ data class EmbyServer(
val iconUrl: String = "",
val authError: String = "",
val isSelected: Boolean = false,
val alternateAddresses: List<EmbyServerAddress> = emptyList(),
) {
val fullUrl: String
get() {
@@ -71,3 +73,86 @@ data class EmbyServer(
fun generateId(): String = "server_${UUID.randomUUID()}"
}
}
data class EmbyServerAddress(
val id: String = generateId(),
val name: String = "",
val protocol: String = "https",
val host: String = "",
val port: String = "",
val basePath: String = "",
) {
val fullUrl: String
get() = buildServerUrl(protocol, host, port, basePath)
companion object {
fun generateId(): String = "address_${UUID.randomUUID()}"
}
}
data class ParsedServerAddress(
val protocol: String,
val host: String,
val port: String,
val basePath: String,
)
fun parseServerAddressInput(
input: String,
currentProtocol: String,
currentPort: String,
currentBasePath: String,
): ParsedServerAddress {
val trimmed = input.trim()
if (trimmed.isEmpty()) {
return ParsedServerAddress(currentProtocol, "", currentPort, currentBasePath)
}
val schemeMatch = Regex("^(https?)://", RegexOption.IGNORE_CASE).find(trimmed)
val hasExplicitScheme = schemeMatch != null
val protocol = schemeMatch?.groupValues?.get(1)?.lowercase()
?: currentProtocol.lowercase().takeIf { it == "http" } ?: "https"
val uriText = if (hasExplicitScheme) trimmed else "$protocol://$trimmed"
val uri = runCatching { URI(uriText) }.getOrNull()
var host = uri?.host.orEmpty()
val explicitPort = uri?.port?.takeIf { it >= 0 }?.toString()
var path = uri?.rawPath.orEmpty().trim('/')
if (host.isEmpty()) {
val withoutScheme = trimmed.replace(Regex("^(https?)://", RegexOption.IGNORE_CASE), "")
val authority = withoutScheme.substringBefore('/').substringBefore('?').substringBefore('#')
val portMatch = Regex("^(.+):(\\d+)$").matchEntire(authority)
host = portMatch?.groupValues?.get(1) ?: authority
if (path.isEmpty() && withoutScheme.contains('/')) {
path = withoutScheme.substringAfter('/').substringBefore('?').substringBefore('#').trim('/')
}
}
val port = when {
explicitPort != null -> explicitPort
hasExplicitScheme && protocol == "https" -> "443"
hasExplicitScheme -> ""
currentPort.isNotBlank() -> currentPort
protocol == "https" -> "443"
else -> ""
}
return ParsedServerAddress(
protocol = protocol,
host = host.trim().trimEnd('/'),
port = port,
basePath = path.ifBlank { currentBasePath.trim().trim('/') },
)
}
private fun buildServerUrl(protocol: String, host: String, port: String, basePath: String): String {
val effectiveProtocol = if (protocol.equals("http", ignoreCase = true)) "http" else "https"
val cleanHost = host.trim()
.replace(Regex("^(https?)://", RegexOption.IGNORE_CASE), "")
.substringBefore('/')
.trimEnd('/')
val base = if (port.isNotBlank()) "$effectiveProtocol://$cleanHost:$port" else "$effectiveProtocol://$cleanHost"
return listOf(base.trimEnd('/'), basePath.trim().trim('/'))
.filter { it.isNotBlank() }
.joinToString("/")
}
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
object ServerRepository {
@@ -184,6 +185,7 @@ object ServerRepository {
server.userId,
server.iconUrl,
if (server.isSelected) 1 else 0,
encodeAlternateAddresses(server.alternateAddresses),
)
}
@@ -202,6 +204,45 @@ object ServerRepository {
userId = entity.userId ?: "",
iconUrl = entity.iconUrl ?: "",
isSelected = entity.isSelected == 1,
alternateAddresses = decodeAlternateAddresses(entity.alternateAddresses),
)
}
private fun encodeAlternateAddresses(addresses: List<EmbyServerAddress>): String {
val array = JSONArray()
addresses.forEach { address ->
array.put(
JSONObject()
.put("id", address.id)
.put("name", address.name)
.put("protocol", address.protocol)
.put("host", address.host)
.put("port", address.port)
.put("basePath", address.basePath)
)
}
return array.toString()
}
private fun decodeAlternateAddresses(value: String?): List<EmbyServerAddress> {
if (value.isNullOrBlank()) return emptyList()
return runCatching {
val array = JSONArray(value)
buildList {
for (index in 0 until array.length()) {
val item = array.getJSONObject(index)
add(
EmbyServerAddress(
id = item.optString("id").ifBlank { EmbyServerAddress.generateId() },
name = item.optString("name"),
protocol = item.optString("protocol", "https"),
host = item.optString("host"),
port = item.optString("port"),
basePath = item.optString("basePath"),
)
)
}
}
}.getOrDefault(emptyList())
}
}
@@ -122,14 +122,14 @@ fun AppNavHost(
selectedServer = selectedServer,
onSelectServer = onSelectServer,
onDeleteServer = onDeleteServer,
onUpdateServer = onUpdateServer,
onEditServer = { server -> navigateOnce(AppRoute.ServerEditor.create(server.id)) },
isGrid = isGrid,
onChangeIconTarget = changeIconTarget,
onDismissChangeIcon = onDismissChangeIcon,
onPickIcon = onPickIcon,
onRequestChangeIcon = onRequestChangeIcon,
onViewToggle = { isGrid = !isGrid },
onAdd = { navigateOnce(AppRoute.AddServer.pattern) },
onAdd = { navigateOnce(AppRoute.ServerEditor.create()) },
onOpenIconLibraryManager = { navigateOnce(AppRoute.IconLibraryManager.pattern) },
)
}
@@ -229,10 +229,16 @@ fun AppNavHost(
AboutScreen(onBack = { navController.popBackStack() })
}
composable(AppRoute.AddServer.pattern) {
composable(
route = AppRoute.ServerEditor.pattern,
arguments = listOf(navArgument("serverId") { type = NavType.StringType }),
) { entry ->
val serverId = entry.arguments?.getString("serverId").routeArgumentOrNull()
val editingServer = serverId?.let { id -> servers.firstOrNull { it.id == id } }
AddServerScreen(
initial = editingServer,
onDismiss = { navController.popBackStack() },
onConfirm = onAddServer,
onConfirm = if (serverId == null) onAddServer else onUpdateServer,
)
}
}
@@ -22,7 +22,10 @@ sealed interface AppRoute {
data object IconLibraryManager : AppRoute { override val pattern = "library/icons" }
data object CacheManagement : AppRoute { override val pattern = "settings/cache" }
data object About : AppRoute { override val pattern = "settings/about" }
data object AddServer : AppRoute { override val pattern = "library/add-server" }
data object ServerEditor : AppRoute {
override val pattern = "library/server/{serverId}"
fun create(serverId: String? = null) = "library/server/${(serverId ?: "-").encode()}"
}
}
private fun String.encode(): String = Uri.encode(this)
@@ -62,14 +62,26 @@ fun AnimatedContentTransitionScope<NavBackStackEntry>.appExitTransition(): ExitT
}
}
fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopEnterTransition(): EnterTransition =
slideInHorizontally(
fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopEnterTransition(): EnterTransition {
if (isReturningFromSettingsChild()) return EnterTransition.None
return slideInHorizontally(
animationSpec = tween(NavigationMotionTokens.detailPopDuration, easing = NavigationMotionTokens.popEasing),
) { width -> -(width * NavigationMotionTokens.detailParallax).toInt() } +
fadeIn(tween(220))
}
fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopExitTransition(): ExitTransition =
slideOutHorizontally(
fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopExitTransition(): ExitTransition {
if (isReturningFromSettingsChild()) return ExitTransition.None
return slideOutHorizontally(
animationSpec = tween(NavigationMotionTokens.detailPopDuration, easing = NavigationMotionTokens.popEasing),
) { width -> width } +
fadeOut(tween(220))
}
private fun AnimatedContentTransitionScope<NavBackStackEntry>.isReturningFromSettingsChild(): Boolean {
if (targetState.destination.route != AppRoute.Settings.pattern) return false
return when (initialState.destination.route) {
AppRoute.CacheManagement.pattern, AppRoute.About.pattern -> true
else -> false
}
}
@@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
@@ -622,62 +621,28 @@ private fun ResourceStatsDialog(
surfaceTint = dialogTint,
blurRadius = 22.dp,
) {
Box(Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(Res.string.resource_stats_title),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
color = GlassTokens.text,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
ResourceStatsGroup(
title = stringResource(Res.string.resource_with_versions),
labels = labels,
values = stats.withVersions.run { listOf(movieCount, seriesCount, episodeCount) },
)
ResourceStatsGroup(
title = stringResource(Res.string.resource_without_versions),
labels = labels,
values = stats.withoutVersions.run { listOf(movieCount, seriesCount, episodeCount) },
)
}
val closeInteraction = remember { MutableInteractionSource() }
Box(
Modifier
.align(Alignment.TopEnd)
.offset(x = 10.dp, y = (-10).dp)
.size(40.dp)
.lumaGlass(
backdrop = backdrop,
radius = 20.dp,
level = LumaGlassLevel.Interactive,
tint = if (GlassTokens.isDark) {
Color(0xFF334252).copy(alpha = 0.48f)
} else {
Color.White.copy(alpha = 0.36f)
},
shadowElevation = 4.dp,
)
.clickable(
interactionSource = closeInteraction,
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
LumaIconView(
icon = LumaIcon.Close,
contentDescription = stringResource(Res.string.resource_close),
tint = GlassTokens.text,
modifier = Modifier.size(17.dp),
)
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(Res.string.resource_stats_title),
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
color = GlassTokens.text,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
)
ResourceStatsGroup(
title = stringResource(Res.string.resource_with_versions),
labels = labels,
values = stats.withVersions.run { listOf(movieCount, seriesCount, episodeCount) },
)
ResourceStatsGroup(
title = stringResource(Res.string.resource_without_versions),
labels = labels,
values = stats.withoutVersions.run { listOf(movieCount, seriesCount, episodeCount) },
)
}
}
}
@@ -60,6 +60,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
@@ -71,6 +72,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.window.Dialog
@@ -83,9 +85,11 @@ import androidx.compose.ui.res.stringResource
import com.example.myapplication.R as Res
import top.yukonga.miuix.kmp.basic.Text
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.data.EmbyServerAddress
import com.example.myapplication.data.IconItem
import com.example.myapplication.data.IconLibrary
import com.example.myapplication.data.IconLibraryRepository
import com.example.myapplication.data.parseServerAddressInput
import com.example.myapplication.ui.GlassSurface
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LocalGlassBackdrop
@@ -173,7 +177,11 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC
role = Role.Button,
) { clicked = true; onClick() },
cornerRadius = 20.dp,
fill = Color.Transparent,
border = Color.Unspecified,
shadowElevation = 0.dp,
padding = PaddingValues(0.dp),
highlight = false,
) {
Row(modifier = Modifier.fillMaxWidth().background(pressBg).padding(14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
@@ -241,7 +249,11 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit,
role = Role.Button,
) { clicked = true; onClick() },
cornerRadius = 20.dp,
fill = Color.Transparent,
border = Color.Unspecified,
shadowElevation = 0.dp,
padding = PaddingValues(0.dp),
highlight = false,
) {
Column(modifier = Modifier.fillMaxWidth().background(pressBg).padding(14.dp), horizontalAlignment = Alignment.Start) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -319,7 +331,7 @@ fun LibraryScreen(
selectedServer: EmbyServer? = null,
onSelectServer: (String) -> Unit = {},
onDeleteServer: (String) -> Unit = {},
onUpdateServer: (EmbyServer, (String?) -> Unit) -> Unit = { _, _ -> },
onEditServer: (EmbyServer) -> Unit = {},
isGrid: Boolean = false,
onChangeIconTarget: EmbyServer? = null,
onDismissChangeIcon: () -> Unit = {},
@@ -330,7 +342,6 @@ fun LibraryScreen(
onOpenIconLibraryManager: () -> Unit = {},
showTopBar: Boolean = true,
) {
var editingServer by remember { mutableStateOf<EmbyServer?>(null) }
var popupServer by remember { mutableStateOf<EmbyServer?>(null) }
var popupAnchor by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
var popupVisible by remember { mutableStateOf(false) }
@@ -338,16 +349,6 @@ fun LibraryScreen(
var rootOrigin by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
val libraryBackdrop = rememberLumaLayerBackdrop()
if (editingServer != null) {
AddServerDialog(
backdrop = libraryBackdrop,
initial = editingServer,
onDismiss = { editingServer = null },
onConfirm = { updated, onResult ->
onUpdateServer(updated, onResult)
},
)
}
if (onChangeIconTarget != null) {
IconPickerDialog(
server = onChangeIconTarget,
@@ -384,10 +385,10 @@ fun LibraryScreen(
} else {
LazyVerticalGrid(
columns = if (isGrid) GridCells.Adaptive(minSize = 150.dp) else GridCells.Fixed(1),
modifier = Modifier.fillMaxSize().statusBarsPadding().padding(horizontal = if (isGrid) 16.dp else 0.dp, vertical = 0.dp).padding(top = 78.dp),
modifier = Modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 16.dp).padding(top = 78.dp),
contentPadding = PaddingValues(bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 10.dp),
horizontalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Servers section
items(servers.size) { index ->
@@ -399,7 +400,7 @@ fun LibraryScreen(
onClick = { onSelectServer(server.id) },
onChangeIcon = { onRequestChangeIcon(server) },
onDelete = { onDeleteServer(server.id) },
onEdit = { editingServer = server },
onEdit = { onEditServer(server) },
onMorePositioned = { coordinates ->
if (popupServer?.id == server.id) popupAnchor = coordinates.boundsInRoot()
},
@@ -415,7 +416,7 @@ fun LibraryScreen(
onClick = { onSelectServer(server.id) },
onChangeIcon = { onRequestChangeIcon(server) },
onDelete = { onDeleteServer(server.id) },
onEdit = { editingServer = server },
onEdit = { onEditServer(server) },
onMorePositioned = { coordinates ->
if (popupServer?.id == server.id) popupAnchor = coordinates.boundsInRoot()
},
@@ -456,7 +457,7 @@ fun LibraryScreen(
GlassPopupItem(
text = stringResource(Res.string.server_edit),
icon = LumaIcon.Edit,
onClick = { popupVisible = false; editingServer = currentPopupServer },
onClick = { popupVisible = false; onEditServer(currentPopupServer) },
)
GlassPopupItem(
text = stringResource(Res.string.change_icon_action),
@@ -593,7 +594,6 @@ internal fun IconLibraryManagerScreenContent(
item(span = { GridItemSpan(2) }) {
Box(
Modifier.fillMaxWidth()
.lumaGlass(radius = LumaShapes.large, level = com.example.myapplication.ui.design.LumaGlassLevel.Regular)
.padding(vertical = 54.dp),
contentAlignment = Alignment.Center,
) {
@@ -662,7 +662,14 @@ internal fun IconLibraryManagerScreenContent(
private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) {
Box(
Modifier.fillMaxWidth()
.lumaGlass(radius = LumaShapes.large, level = com.example.myapplication.ui.design.LumaGlassLevel.Regular)
.lumaGlass(
radius = LumaShapes.large,
level = com.example.myapplication.ui.design.LumaGlassLevel.Clear,
tint = Color.Transparent,
border = false,
shadowElevation = 0.dp,
highlight = false,
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
@@ -1276,6 +1283,7 @@ private fun IconPickerDialog(
@Composable
fun AddServerScreen(
initial: EmbyServer? = null,
onDismiss: () -> Unit,
onConfirm: (EmbyServer, (String?) -> Unit) -> Unit,
) {
@@ -1295,61 +1303,78 @@ fun AddServerScreen(
LumaIconView(LumaIcon.Back, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(20.dp))
}
Spacer(Modifier.width(12.dp))
Text(stringResource(Res.string.add_server), color = GlassTokens.text, fontSize = 22.sp, fontWeight = FontWeight.Bold)
Text(
stringResource(if (initial == null) Res.string.add_server else Res.string.edit_server),
color = GlassTokens.text,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
}
ServerFormContent(
initial = null,
initial = initial,
onDismiss = onDismiss,
onConfirm = onConfirm,
useGlass = false,
showHeader = false,
modifier = Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState())
.navigationBarsPadding().padding(horizontal = 18.dp, vertical = 8.dp),
)
}
}
@Composable
private fun AddServerDialog(
backdrop: Backdrop? = null,
initial: EmbyServer? = null,
onDismiss: () -> Unit,
onConfirm: (EmbyServer, (String?) -> Unit) -> Unit,
) {
GlassDialog(backdrop = backdrop, onDismissRequest = onDismiss) {
ServerFormContent(
initial = initial,
onDismiss = onDismiss,
onConfirm = onConfirm,
useGlass = true,
showHeader = true,
modifier = Modifier.width(300.dp).verticalScroll(rememberScrollState()),
)
}
}
private data class AlternateAddressForm(
val id: String,
val name: TextFieldValue,
val host: TextFieldValue,
val port: TextFieldValue,
val basePath: TextFieldValue,
val useHttps: Boolean,
)
private fun String.toEndSelectedFieldValue(): TextFieldValue =
TextFieldValue(this, selection = TextRange(length))
private fun EmbyServerAddress.toForm(): AlternateAddressForm = AlternateAddressForm(
id = id,
name = name.toEndSelectedFieldValue(),
host = host.toEndSelectedFieldValue(),
port = port.toEndSelectedFieldValue(),
basePath = basePath.toEndSelectedFieldValue(),
useHttps = protocol.equals("https", ignoreCase = true),
)
@Composable
private fun ServerFormContent(
initial: EmbyServer?,
onDismiss: () -> Unit,
onConfirm: (EmbyServer, (String?) -> Unit) -> Unit,
useGlass: Boolean,
showHeader: Boolean,
modifier: Modifier = Modifier,
) {
var name by remember { mutableStateOf(TextFieldValue(initial?.name ?: "")) }
var host by remember { mutableStateOf(TextFieldValue(initial?.host ?: "")) }
var port by remember { mutableStateOf(TextFieldValue(initial?.port ?: "")) }
var basePath by remember { mutableStateOf(TextFieldValue(initial?.basePath ?: "")) }
var username by remember { mutableStateOf(TextFieldValue(initial?.username ?: "")) }
var password by remember { mutableStateOf(TextFieldValue(initial?.password ?: "")) }
var note by remember { mutableStateOf(TextFieldValue(initial?.note ?: "")) }
val initialAddress = remember(initial?.id, initial?.host, initial?.protocol, initial?.port, initial?.basePath) {
initial?.let {
parseServerAddressInput(
input = it.host,
currentProtocol = it.protocol,
currentPort = it.port,
currentBasePath = it.basePath,
)
}
}
var name by remember(initial?.id) { mutableStateOf(TextFieldValue(initial?.name ?: "")) }
var host by remember(initial?.id) { mutableStateOf(TextFieldValue(initialAddress?.host ?: "")) }
var port by remember(initial?.id) { mutableStateOf(TextFieldValue(initialAddress?.port ?: "")) }
var basePath by remember(initial?.id) { mutableStateOf(TextFieldValue(initialAddress?.basePath ?: "")) }
var username by remember(initial?.id) { mutableStateOf(TextFieldValue(initial?.username ?: "")) }
var password by remember(initial?.id) { mutableStateOf(TextFieldValue(initial?.password ?: "")) }
var note by remember(initial?.id) { mutableStateOf(TextFieldValue(initial?.note ?: "")) }
var alternateAddresses by remember(initial?.id) {
mutableStateOf(initial?.alternateAddresses.orEmpty().map(EmbyServerAddress::toForm))
}
var isSaving by remember { mutableStateOf(false) }
var authError by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var showValidation by remember { mutableStateOf(false) }
var useHttps by remember { mutableStateOf(initial?.protocol?.equals("https", ignoreCase = true) ?: true) }
var useHttps by remember(initial?.id) { mutableStateOf(initialAddress?.protocol?.equals("https", ignoreCase = true) ?: true) }
val justNowLabel = stringResource(Res.string.server_just_now)
val alternateIncompleteLabel = stringResource(Res.string.server_alternate_incomplete)
val hostFocusRequester = remember { FocusRequester() }
val portFocusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }
@@ -1357,22 +1382,65 @@ private fun ServerFormContent(
val portError = showValidation && port.text.trim().isEmpty()
val usernameError = showValidation && username.text.trim().isEmpty()
fun normalizePrimaryAddress() = parseServerAddressInput(
input = host.text,
currentProtocol = if (useHttps) "https" else "http",
currentPort = port.text,
currentBasePath = basePath.text,
).also { parsed ->
host = parsed.host.toEndSelectedFieldValue()
port = parsed.port.toEndSelectedFieldValue()
basePath = parsed.basePath.toEndSelectedFieldValue()
useHttps = parsed.protocol == "https"
}
fun normalizedAlternateAddresses(): List<AlternateAddressForm> = alternateAddresses.map { address ->
val parsed = parseServerAddressInput(
input = address.host.text,
currentProtocol = if (address.useHttps) "https" else "http",
currentPort = address.port.text,
currentBasePath = address.basePath.text,
)
address.copy(
host = parsed.host.toEndSelectedFieldValue(),
port = parsed.port.toEndSelectedFieldValue(),
basePath = parsed.basePath.toEndSelectedFieldValue(),
useHttps = parsed.protocol == "https",
)
}
fun submit() {
if (isSaving) return
authError = ""
showValidation = true
val primaryAddress = normalizePrimaryAddress()
val normalizedAlternates = normalizedAlternateAddresses()
alternateAddresses = normalizedAlternates
when {
host.text.trim().isEmpty() -> runCatching { hostFocusRequester.requestFocus() }
port.text.trim().isEmpty() -> runCatching { portFocusRequester.requestFocus() }
primaryAddress.host.isEmpty() -> runCatching { hostFocusRequester.requestFocus() }
primaryAddress.port.isEmpty() -> runCatching { portFocusRequester.requestFocus() }
username.text.trim().isEmpty() -> runCatching { usernameFocusRequester.requestFocus() }
normalizedAlternates.any { it.name.text.isBlank() || it.host.text.isBlank() || it.port.text.isBlank() } -> {
authError = alternateIncompleteLabel
}
else -> {
isSaving = true
val server = (initial ?: EmbyServer(id = EmbyServer.generateId())).copy(
name = name.text.trim(), host = host.text.trim(), port = port.text.trim(),
protocol = if (useHttps) "https" else "http",
basePath = basePath.text.trim().trim('/'),
name = name.text.trim(), host = primaryAddress.host, port = primaryAddress.port,
protocol = primaryAddress.protocol,
basePath = primaryAddress.basePath,
username = username.text.trim(), password = password.text.trim(), note = note.text.trim(),
isOnline = initial?.isOnline ?: true, lastActive = initial?.lastActive ?: justNowLabel,
alternateAddresses = normalizedAlternates.map { address ->
EmbyServerAddress(
id = address.id,
name = address.name.text.trim(),
protocol = if (address.useHttps) "https" else "http",
host = address.host.text.trim(),
port = address.port.text.trim(),
basePath = address.basePath.text.trim().trim('/'),
)
},
)
onConfirm(server) { error ->
isSaving = false
@@ -1383,26 +1451,13 @@ private fun ServerFormContent(
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (showHeader) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
.background(Brush.linearGradient(listOf(Color(0xFF6EA8FF), Color(0xFF9B6BFF)))),
contentAlignment = Alignment.Center,
) {
LumaIconView(LumaIcon.Add, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
Spacer(Modifier.width(10.dp))
Text(text = stringResource(if (initial == null) Res.string.add_server else Res.string.edit_server), fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text)
}
}
ServerFormInput(useGlass = useGlass, label = stringResource(Res.string.server_name_label), value = name, placeholder = stringResource(Res.string.server_name_placeholder), onValueChange = { name = it })
ServerFormInput(label = stringResource(Res.string.server_name_label), value = name, placeholder = stringResource(Res.string.server_name_placeholder), onValueChange = { name = it })
ServerFormInput(
useGlass = useGlass,
label = stringResource(Res.string.server_url_label),
value = host,
placeholder = "emby.example.com",
onValueChange = { host = it },
onFocusLost = { normalizePrimaryAddress() },
isError = hostError,
errorMessage = if (hostError) stringResource(Res.string.server_url_error) else null,
focusRequester = hostFocusRequester,
@@ -1416,11 +1471,16 @@ private fun ServerFormContent(
fontSize = 12.sp,
)
}
ProtocolSwitch(checked = useHttps, onCheckedChange = { useHttps = it })
ProtocolSwitch(
checked = useHttps,
onCheckedChange = {
useHttps = it
if (it && port.text.isBlank()) port = "443".toEndSelectedFieldValue()
},
)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ServerFormInput(
useGlass = useGlass,
label = stringResource(Res.string.server_path_label),
value = basePath,
placeholder = "/emby",
@@ -1428,7 +1488,6 @@ private fun ServerFormContent(
modifier = Modifier.weight(1f),
)
ServerFormInput(
useGlass = useGlass,
label = stringResource(Res.string.server_port_label),
value = port,
placeholder = "8096",
@@ -1439,8 +1498,137 @@ private fun ServerFormContent(
focusRequester = portFocusRequester,
)
}
Text(
text = stringResource(Res.string.server_alternate_title),
color = GlassTokens.text,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp),
)
Text(
text = stringResource(Res.string.server_alternate_subtitle),
color = GlassTokens.subtext,
fontSize = 12.sp,
)
alternateAddresses.forEachIndexed { index, address ->
Column(
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(LumaShapes.medium))
.border(0.8.dp, LumaColors.glassBorder, RoundedCornerShape(LumaShapes.medium))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(Res.string.server_alternate_item, index + 1),
color = GlassTokens.text,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
Box(
Modifier.size(40.dp).clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
) {
alternateAddresses = alternateAddresses.filterNot { it.id == address.id }
},
contentAlignment = Alignment.Center,
) {
LumaIconView(
LumaIcon.Delete,
contentDescription = stringResource(Res.string.server_alternate_delete),
tint = offlineRed,
modifier = Modifier.size(20.dp),
)
}
}
ServerFormInput(
label = stringResource(Res.string.server_alternate_name),
value = address.name,
placeholder = stringResource(Res.string.server_alternate_name_placeholder),
onValueChange = { value ->
alternateAddresses = alternateAddresses.map { if (it.id == address.id) it.copy(name = value) else it }
},
)
ServerFormInput(
label = stringResource(Res.string.server_url_label),
value = address.host,
placeholder = "backup.example.com",
onValueChange = { value ->
alternateAddresses = alternateAddresses.map { if (it.id == address.id) it.copy(host = value) else it }
},
onFocusLost = {
alternateAddresses = alternateAddresses.map {
if (it.id != address.id) return@map it
val parsed = parseServerAddressInput(
input = it.host.text,
currentProtocol = if (it.useHttps) "https" else "http",
currentPort = it.port.text,
currentBasePath = it.basePath.text,
)
it.copy(
host = parsed.host.toEndSelectedFieldValue(),
port = parsed.port.toEndSelectedFieldValue(),
basePath = parsed.basePath.toEndSelectedFieldValue(),
useHttps = parsed.protocol == "https",
)
}
},
)
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stringResource(Res.string.server_protocol), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(
stringResource(if (address.useHttps) Res.string.server_protocol_https_sub else Res.string.server_protocol_http_sub),
color = GlassTokens.subtext,
fontSize = 12.sp,
)
}
ProtocolSwitch(
checked = address.useHttps,
onCheckedChange = { checked ->
alternateAddresses = alternateAddresses.map {
if (it.id == address.id) {
it.copy(
useHttps = checked,
port = if (checked && it.port.text.isBlank()) "443".toEndSelectedFieldValue() else it.port,
)
} else it
}
},
)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
ServerFormInput(
label = stringResource(Res.string.server_path_label),
value = address.basePath,
placeholder = "/emby",
onValueChange = { value ->
alternateAddresses = alternateAddresses.map { if (it.id == address.id) it.copy(basePath = value) else it }
},
modifier = Modifier.weight(1f),
)
ServerFormInput(
label = stringResource(Res.string.server_port_label),
value = address.port,
placeholder = "443",
onValueChange = { value ->
alternateAddresses = alternateAddresses.map { if (it.id == address.id) it.copy(port = value) else it }
},
modifier = Modifier.weight(1f),
)
}
}
}
PlainButton(
text = stringResource(Res.string.server_alternate_add),
onClick = {
alternateAddresses = alternateAddresses + EmbyServerAddress(port = "443").toForm()
},
modifier = Modifier.fillMaxWidth(),
)
ServerFormInput(
useGlass = useGlass,
label = stringResource(Res.string.server_username_label),
value = username,
placeholder = stringResource(Res.string.server_username_placeholder),
@@ -1450,7 +1638,6 @@ private fun ServerFormContent(
focusRequester = usernameFocusRequester,
)
ServerFormInput(
useGlass = useGlass,
label = stringResource(Res.string.server_password_label),
value = password,
placeholder = stringResource(Res.string.server_password_placeholder),
@@ -1475,41 +1662,30 @@ private fun ServerFormContent(
}
},
)
ServerFormInput(useGlass = useGlass, label = stringResource(Res.string.server_note_label), value = note, placeholder = stringResource(Res.string.server_note_placeholder), onValueChange = { note = it })
ServerFormInput(label = stringResource(Res.string.server_note_label), value = note, placeholder = stringResource(Res.string.server_note_placeholder), onValueChange = { note = it })
if (authError.isNotBlank()) {
Text(text = authError, color = offlineRed, fontSize = 13.sp, modifier = Modifier.padding(bottom = 4.dp))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
if (useGlass) {
GlassButton(text = stringResource(Res.string.server_cancel), onClick = onDismiss, modifier = Modifier.weight(1f))
GlassButton(
text = if (isSaving) stringResource(Res.string.server_saving) else stringResource(Res.string.server_save),
onClick = { submit() },
modifier = Modifier.weight(1f),
accent = true,
enabled = !isSaving,
)
} else {
PlainButton(text = stringResource(Res.string.server_cancel), onClick = onDismiss, modifier = Modifier.weight(1f))
PlainButton(
text = if (isSaving) stringResource(Res.string.server_saving) else stringResource(Res.string.server_save),
onClick = { submit() },
modifier = Modifier.weight(1f),
accent = true,
enabled = !isSaving,
)
}
PlainButton(text = stringResource(Res.string.server_cancel), onClick = onDismiss, modifier = Modifier.weight(1f))
PlainButton(
text = if (isSaving) stringResource(Res.string.server_saving) else stringResource(Res.string.server_save),
onClick = { submit() },
modifier = Modifier.weight(1f),
accent = true,
enabled = !isSaving,
)
}
}
}
@Composable
private fun ServerFormInput(
useGlass: Boolean,
label: String,
value: TextFieldValue,
placeholder: String,
onValueChange: (TextFieldValue) -> Unit,
onFocusLost: () -> Unit = {},
modifier: Modifier = Modifier,
visualTransformation: VisualTransformation = VisualTransformation.None,
trailingContent: (@Composable () -> Unit)? = null,
@@ -1517,33 +1693,19 @@ private fun ServerFormInput(
errorMessage: String? = null,
focusRequester: FocusRequester? = null,
) {
if (useGlass) {
GlassInputField(
label = label,
value = value,
placeholder = placeholder,
onValueChange = onValueChange,
modifier = modifier,
visualTransformation = visualTransformation,
trailingContent = trailingContent,
isError = isError,
errorMessage = errorMessage,
focusRequester = focusRequester,
)
} else {
PlainInputField(
label = label,
value = value,
placeholder = placeholder,
onValueChange = onValueChange,
modifier = modifier,
visualTransformation = visualTransformation,
trailingContent = trailingContent,
isError = isError,
errorMessage = errorMessage,
focusRequester = focusRequester,
)
}
PlainInputField(
label = label,
value = value,
placeholder = placeholder,
onValueChange = onValueChange,
onFocusLost = onFocusLost,
modifier = modifier,
visualTransformation = visualTransformation,
trailingContent = trailingContent,
isError = isError,
errorMessage = errorMessage,
focusRequester = focusRequester,
)
}
@Composable
@@ -1552,6 +1714,7 @@ private fun PlainInputField(
value: TextFieldValue,
placeholder: String,
onValueChange: (TextFieldValue) -> Unit,
onFocusLost: () -> Unit = {},
modifier: Modifier = Modifier,
visualTransformation: VisualTransformation = VisualTransformation.None,
trailingContent: (@Composable () -> Unit)? = null,
@@ -1561,23 +1724,30 @@ private fun PlainInputField(
) {
val shape = RoundedCornerShape(LumaShapes.small)
val fieldBackground = LumaColors.backgroundElevated
var hadFocus by remember { mutableStateOf(false) }
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(label, fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
Box(
Modifier.fillMaxWidth().heightIn(min = 48.dp).clip(shape).background(fieldBackground)
Modifier.fillMaxWidth().height(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),
.padding(horizontal = 14.dp),
contentAlignment = Alignment.Center,
) {
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 15.sp),
modifier = Modifier.fillMaxWidth().then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier),
modifier = Modifier.fillMaxWidth()
.then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier)
.onFocusChanged { focusState ->
if (hadFocus && !focusState.isFocused) onFocusLost()
hadFocus = focusState.isFocused
},
visualTransformation = visualTransformation,
decorationBox = { inner ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
if (value.text.isEmpty()) Text(placeholder, color = GlassTokens.subtext2, fontSize = 15.sp)
inner()
}
@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -30,6 +29,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
@@ -42,34 +42,25 @@ import com.example.myapplication.ui.GlassConfirmDialog
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LocalGlassBackdrop
import com.example.myapplication.ui.design.LumaColors
import com.example.myapplication.ui.design.LumaGlassRuntime
import com.example.myapplication.ui.design.rememberLumaLayerBackdrop
import com.example.myapplication.ui.design.LumaIcon
import com.example.myapplication.ui.design.LumaIconView
import com.example.myapplication.ui.design.LumaGlassLevel
import com.example.myapplication.ui.design.LumaPageTopBar
import com.example.myapplication.ui.design.lumaGlass
import com.example.myapplication.ui.design.LumaShapes
import com.example.myapplication.ui.design.LumaSpacing
import com.example.myapplication.ui.design.LumaTypography
import top.yukonga.miuix.kmp.basic.Text
import com.kyant.backdrop.backdrops.layerBackdrop
@Composable
fun CacheManagementScreen(onBack: () -> Unit) {
var cacheSize by remember { mutableLongStateOf(IconLibraryRepository.getIconCacheSize()) }
var showClearCacheConfirm by remember { mutableStateOf(false) }
val cacheBackdrop = rememberLumaLayerBackdrop()
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
.background(LumaColors.background)
.then(
if (cacheBackdrop != null) Modifier.layerBackdrop(cacheBackdrop) else Modifier,
),
.background(LumaColors.background),
)
CompositionLocalProvider(LocalGlassBackdrop provides cacheBackdrop) {
CompositionLocalProvider(LocalGlassBackdrop provides null) {
Column(Modifier.fillMaxSize()) {
LumaPageTopBar(
title = stringResource(R.string.cache_detail_title),
@@ -92,7 +83,7 @@ fun CacheManagementScreen(onBack: () -> Unit) {
Box(
Modifier
.fillMaxWidth()
.lumaGlass(radius = LumaShapes.medium, level = LumaGlassLevel.Regular)
.settingsModuleSurface(radius = LumaShapes.medium)
.clickable { showClearCacheConfirm = true }
.padding(horizontal = 16.dp, vertical = 15.dp),
contentAlignment = Alignment.CenterStart,
@@ -113,7 +104,7 @@ fun CacheManagementScreen(onBack: () -> Unit) {
message = stringResource(R.string.confirm_clear_cache_message),
confirmText = stringResource(R.string.confirm_clear),
dismissText = stringResource(R.string.confirm_cancel),
backdrop = cacheBackdrop,
backdrop = null,
onDismissRequest = { showClearCacheConfirm = false },
onConfirm = {
IconLibraryRepository.clearAllIconCache()
@@ -130,7 +121,7 @@ private fun CacheInfoCard(title: String, value: String, icon: LumaIcon) {
Row(
Modifier
.fillMaxWidth()
.lumaGlass(radius = LumaShapes.medium, level = LumaGlassLevel.Regular)
.settingsModuleSurface(radius = LumaShapes.medium)
.padding(horizontal = 16.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -152,17 +143,13 @@ private fun CacheInfoCard(title: String, value: String, icon: LumaIcon) {
@Composable
fun AboutScreen(onBack: () -> Unit) {
val aboutBackdrop = rememberLumaLayerBackdrop()
Box(Modifier.fillMaxSize()) {
Box(
Modifier
.fillMaxSize()
.background(LumaColors.background)
.then(
if (aboutBackdrop != null) Modifier.layerBackdrop(aboutBackdrop) else Modifier,
),
.background(LumaColors.background),
)
CompositionLocalProvider(LocalGlassBackdrop provides aboutBackdrop) {
CompositionLocalProvider(LocalGlassBackdrop provides null) {
Column(Modifier.fillMaxSize()) {
LumaPageTopBar(
title = stringResource(R.string.about),
@@ -176,7 +163,10 @@ fun AboutScreen(onBack: () -> Unit) {
) {
item {
Column(
Modifier.fillMaxWidth().padding(vertical = LumaSpacing.sm),
Modifier
.fillMaxWidth()
.settingsModuleSurface(radius = LumaShapes.large)
.padding(horizontal = LumaSpacing.md, vertical = LumaSpacing.md),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
@@ -308,7 +298,7 @@ private fun AboutSectionCard(title: String, content: @Composable ColumnScope.()
Column(
Modifier
.fillMaxWidth()
.lumaGlass(radius = LumaShapes.large, level = LumaGlassLevel.Regular)
.settingsModuleSurface(radius = LumaShapes.large)
.padding(vertical = LumaSpacing.xs),
) {
Text(
@@ -342,6 +332,15 @@ private fun AboutInfoRow(icon: LumaIcon, title: String, subtitle: String) {
}
}
internal fun Modifier.settingsModuleSurface(radius: Dp): Modifier {
val shape = RoundedCornerShape(radius)
val fill = if (LumaColors.isDark) LumaColors.backgroundElevated else Color.White
val border = if (LumaColors.isDark) LumaColors.glassBorder else Color(0xFFE1E7EE)
return clip(shape)
.background(fill, shape)
.border(0.7.dp, border, shape)
}
private fun formatCacheSize(bytes: Long): String = when {
bytes < 1024L -> "$bytes B"
bytes < 1024L * 1024L -> "%.1f KB".format(bytes / 1024f)
@@ -1,11 +1,7 @@
package com.example.myapplication.screens
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -17,26 +13,20 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
@@ -44,15 +34,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import com.example.myapplication.R
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.ui.AppLanguage
@@ -66,28 +52,16 @@ import com.example.myapplication.ui.bottomBarContentPadding
import com.example.myapplication.ui.design.LumaColors
import com.example.myapplication.ui.design.LumaIcon
import com.example.myapplication.ui.design.LumaIconView
import com.example.myapplication.ui.design.LumaGlassRuntime
import com.example.myapplication.ui.design.rememberLumaLayerBackdrop
import com.example.myapplication.ui.design.LumaMotion
import com.example.myapplication.ui.design.LumaShapes
import com.example.myapplication.ui.design.LumaSpacing
import com.example.myapplication.ui.design.LumaSettingRow
import com.example.myapplication.ui.design.LumaSectionLabel
import com.example.myapplication.ui.design.LumaTypography
import com.example.myapplication.ui.design.lumaGlass
import com.example.myapplication.ui.design.lumaFallbackGlass
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.colorControls
import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow
import com.kyant.shapes.RoundedRectangle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import top.yukonga.miuix.kmp.basic.Text
@Composable
@@ -117,7 +91,8 @@ fun SettingsScreen(
var settingsRootOrigin by remember { mutableStateOf(Offset.Zero) }
val context = LocalContext.current
val languageScope = rememberCoroutineScope()
val languageBackdrop = rememberLumaLayerBackdrop()
val popupMounted = languageMenuMounted || themeMenuMounted || glassModeMenuMounted
val popupBackdrop = if (popupMounted) rememberLumaLayerBackdrop() else null
Box(
Modifier
@@ -128,11 +103,9 @@ fun SettingsScreen(
Modifier
.fillMaxSize()
.background(LumaColors.background)
.then(
if (languageBackdrop != null) Modifier.layerBackdrop(languageBackdrop) else Modifier,
),
.then(if (popupBackdrop != null) Modifier.layerBackdrop(popupBackdrop) else Modifier),
)
CompositionLocalProvider(LocalGlassBackdrop provides languageBackdrop) {
CompositionLocalProvider(LocalGlassBackdrop provides popupBackdrop) {
Column(
Modifier
.fillMaxSize()
@@ -209,7 +182,7 @@ fun SettingsScreen(
LanguageMenuOverlay(
anchorBounds = anchor,
rootOrigin = settingsRootOrigin,
backdrop = languageBackdrop,
backdrop = popupBackdrop,
visible = showLanguageDialog,
onDismiss = { showLanguageDialog = false },
onClosed = { languageMenuMounted = false },
@@ -227,7 +200,7 @@ fun SettingsScreen(
ThemeMenuOverlay(
anchorBounds = themeAnchor,
rootOrigin = settingsRootOrigin,
backdrop = languageBackdrop,
backdrop = popupBackdrop,
visible = themeMenuVisible,
onDismiss = { themeMenuVisible = false },
onClosed = { themeMenuMounted = false },
@@ -242,7 +215,7 @@ fun SettingsScreen(
GlassModeMenuOverlay(
anchorBounds = glassModeAnchor,
rootOrigin = settingsRootOrigin,
backdrop = languageBackdrop,
backdrop = popupBackdrop,
visible = glassModeMenuVisible,
onDismiss = { glassModeMenuVisible = false },
onClosed = { glassModeMenuMounted = false },
@@ -261,7 +234,7 @@ private fun SettingSectionCard(content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier
.fillMaxWidth()
.lumaGlass(radius = LumaShapes.large, level = com.example.myapplication.ui.design.LumaGlassLevel.Regular)
.settingsModuleSurface(radius = LumaShapes.large)
.padding(vertical = 4.dp),
) {
content()
@@ -304,7 +277,9 @@ private fun ThemeMenuOverlay(
onDismissRequest = onDismiss,
onClosed = onClosed,
width = 190.dp,
height = 184.dp,
height = 152.dp,
containerColor = settingsPopupContainerColor(),
borderColor = settingsPopupBorderColor(),
) {
ThemeOption(
icon = LumaIcon.Image,
@@ -338,14 +313,16 @@ private fun ThemeOption(
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 4.dp)
.clip(RoundedCornerShape(16.dp))
.background(if (selected) LumaColors.accentSoft else Color.Transparent)
.clickable(indication = null, interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, onClick = onClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LumaIconView(icon, contentDescription = null, tint = if (selected) LumaColors.accent else LumaColors.textSecondary, modifier = Modifier.size(19.dp))
Spacer(Modifier.width(10.dp))
Text(label, color = LumaColors.text, fontSize = 14.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, modifier = Modifier.weight(1f))
Text(label, color = LumaColors.text, fontSize = LumaTypography.body, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, modifier = Modifier.weight(1f))
if (selected) {
LumaIconView(LumaIcon.Check, contentDescription = stringResource(R.string.selected), tint = LumaColors.accent, modifier = Modifier.size(18.dp))
}
@@ -370,7 +347,9 @@ private fun GlassModeMenuOverlay(
onDismissRequest = onDismiss,
onClosed = onClosed,
width = 190.dp,
height = 128.dp,
height = 104.dp,
containerColor = settingsPopupContainerColor(),
borderColor = settingsPopupBorderColor(),
) {
ThemeOption(
icon = LumaIcon.Star,
@@ -397,162 +376,41 @@ private fun LanguageMenuOverlay(
onClosed: () -> Unit,
onLanguageSelected: (Int) -> Unit,
) {
val density = LocalDensity.current
val progress = remember { Animatable(0f) }
var hasOpened by remember { mutableStateOf(false) }
LaunchedEffect(visible) {
if (visible) {
hasOpened = true
progress.animateTo(
targetValue = 1f,
animationSpec = tween(LumaMotion.menu, easing = LumaMotion.enter),
)
} else if (hasOpened) {
progress.animateTo(
targetValue = 0f,
animationSpec = tween(LumaMotion.sheet, easing = LumaMotion.exit),
)
onClosed()
}
}
val rawProgress = progress.value.coerceIn(0f, 1f)
val widthProgress = 1f - (1f - rawProgress) * (1f - rawProgress) * (1f - rawProgress)
val heightProgress = 1f - (1f - rawProgress) * (1f - rawProgress)
val contentProgress = ((rawProgress - 0.38f) / 0.62f).coerceIn(0f, 1f)
val initialSize = 44.dp
val finalWidth = 184.dp
val finalHeight = 112.dp
val menuWidth = initialSize + (finalWidth - initialSize) * widthProgress
val menuHeight = initialSize + (finalHeight - initialSize) * heightProgress
val cornerRadius = 23.dp + (28.dp - 23.dp) * rawProgress
val menuWidthPx = with(density) { menuWidth.roundToPx() }
val menuHeightPx = with(density) { menuHeight.roundToPx() }
val initialHalfPx = with(density) { initialSize.toPx() / 2f }
val anchorCenterX = anchorBounds.center.x - rootOrigin.x
val anchorCenterY = anchorBounds.center.y - rootOrigin.y
val menuRight = anchorCenterX + initialHalfPx
val menuLeft = (menuRight - menuWidthPx).roundToInt()
val menuTop = (anchorCenterY - initialHalfPx).roundToInt()
val menuRect = Rect(
menuLeft.toFloat(),
menuTop.toFloat(),
(menuLeft + menuWidthPx).toFloat(),
(menuTop + menuHeightPx).toFloat(),
)
val currentMenuRect by rememberUpdatedState(menuRect)
val shape = RoundedCornerShape(cornerRadius)
val surface = LumaColors.floatingTint
val border = LumaColors.glassBorder
Box(
Modifier
.fillMaxSize()
.zIndex(10f),
LiquidGlassPopupOverlay(
anchorBounds = anchorBounds,
rootOrigin = rootOrigin,
backdrop = backdrop,
visible = visible,
onDismissRequest = onDismiss,
onClosed = onClosed,
width = 190.dp,
height = 104.dp,
containerColor = settingsPopupContainerColor(),
borderColor = settingsPopupBorderColor(),
) {
Box(
Modifier
.fillMaxSize()
.pointerInput(visible) {
detectTapGestures { position ->
if (!currentMenuRect.contains(position)) {
onDismiss()
}
}
},
ThemeOption(
icon = LumaIcon.Globe,
label = stringResource(R.string.lang_chinese),
selected = AppLanguage.language == 0,
onClick = { onLanguageSelected(0) },
)
Box(
Modifier
.offset { IntOffset(menuLeft, menuTop) }
.width(menuWidth)
.height(menuHeight)
.then(
if (backdrop != null && LumaGlassRuntime.useNativeBackdrop) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedRectangle(cornerRadius) },
effects = {
colorControls(
brightness = if (GlassTokens.isDark) 0f else 0.05f,
saturation = 1.10f,
)
vibrancy()
blur(if (GlassTokens.isDark) 10.dp.toPx() else 14.dp.toPx())
},
highlight = { Highlight.Plain },
shadow = { Shadow(alpha = 0.16f) },
onDrawSurface = { drawRect(surface) },
)
} else {
Modifier.lumaFallbackGlass(shape, border = false)
},
)
.border(0.7.dp, border, shape)
.zIndex(1f),
) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 8.dp, vertical = 8.dp)
.alpha(contentProgress)
.offset(y = 6.dp * (1f - contentProgress)),
verticalArrangement = Arrangement.Center,
) {
LanguageOption(
label = stringResource(R.string.lang_chinese),
selected = AppLanguage.language == 0,
onClick = { onLanguageSelected(0) },
)
LanguageOption(
label = stringResource(R.string.lang_english),
selected = AppLanguage.language == 1,
onClick = { onLanguageSelected(1) },
)
}
}
}
}
@Composable
private fun LanguageOption(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(18.dp))
.clickable(
indication = null,
interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() },
onClick = onClick,
)
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
Modifier
.size(20.dp)
.clip(CircleShape)
.background(if (selected) GlassTokens.accent else Color.Transparent)
.border(1.dp, if (selected) GlassTokens.accent else LumaColors.textTertiary, CircleShape),
contentAlignment = Alignment.Center,
) {
if (selected) {
LumaIconView(LumaIcon.Check, tint = Color.White, modifier = Modifier.size(14.dp), contentDescription = stringResource(R.string.selected))
}
}
Spacer(Modifier.width(10.dp))
Text(
text = label,
color = GlassTokens.text,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
ThemeOption(
icon = LumaIcon.Globe,
label = stringResource(R.string.lang_english),
selected = AppLanguage.language == 1,
onClick = { onLanguageSelected(1) },
)
}
}
private fun settingsPopupContainerColor(): Color = if (LumaColors.isDark) {
Color(0xFF111821).copy(alpha = 0.72f)
} else {
Color(0xFFF4F7FA).copy(alpha = 0.68f)
}
private fun settingsPopupBorderColor(): Color = if (LumaColors.isDark) {
Color.White.copy(alpha = 0.24f)
} else {
Color(0xFFC8D2DE)
}
@@ -20,10 +20,13 @@ import androidx.compose.runtime.remember
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -37,6 +40,9 @@ import com.example.myapplication.ui.design.LumaIconView
import com.example.myapplication.ui.design.LumaSpacing
import com.example.myapplication.ui.design.lumaFallbackGlass
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
@@ -66,7 +72,9 @@ fun LiquidAppBottomBar(
Box(modifier = Modifier.weight(1f)) {
LiquidBottomTabs(
selectedTabIndex = { selectedTab.coerceIn(0, 3) },
onTabSelected = onSelect,
onTabSelected = { index ->
if (selectedTab < 4) onSelect(index)
},
backdrop = backdrop,
tabsCount = 4,
showSelection = { selectedTab < 4 },
@@ -99,10 +107,16 @@ fun LiquidAppBottomBar(
val searchSelected = selectedTab == 4
val safeBackdrop = backdrop?.takeIf { LumaGlassRuntime.useNativeBackdrop }
val searchContentBackdrop = rememberLayerBackdrop()
val searchInteraction = remember { MutableInteractionSource() }
val searchPressed by searchInteraction.collectIsPressedAsState()
val searchPressProgress by animateFloatAsState(
targetValue = if (searchPressed) 1f else 0f,
animationSpec = spring(dampingRatio = 0.62f, stiffness = 420f),
label = "bottomSearchPressProgress",
)
val searchScale by animateFloatAsState(
targetValue = if (searchPressed) 1.06f else 1f,
targetValue = if (searchPressed) 1.12f else 1f,
animationSpec = spring(dampingRatio = 0.72f, stiffness = 650f),
label = "bottomSearchPress",
)
@@ -124,6 +138,8 @@ fun LiquidAppBottomBar(
blur(8f.dp.toPx())
lens(24f.dp.toPx(), 24f.dp.toPx())
},
highlight = { Highlight.Default.copy(alpha = searchPressProgress) },
shadow = { Shadow(alpha = searchPressProgress) },
onDrawSurface = {
drawRect(
if (LumaColors.isDark) {
@@ -162,6 +178,24 @@ fun LiquidAppBottomBar(
.clickable(indication = null, interactionSource = searchInteraction) { onSelect(4) },
contentAlignment = Alignment.Center,
) {
if (safeBackdrop != null) {
Box(
Modifier
.clearAndSetSemantics {}
.alpha(0f)
.layerBackdrop(searchContentBackdrop)
.size(56.dp)
.graphicsLayer(colorFilter = ColorFilter.tint(active)),
contentAlignment = Alignment.Center,
) {
LumaIconView(
icon = LumaIcon.Search,
contentDescription = null,
tint = active,
modifier = Modifier.size(27.dp),
)
}
}
if (searchSelected) {
Box(
Modifier
@@ -170,23 +204,35 @@ fun LiquidAppBottomBar(
.then(
if (safeBackdrop != null) {
Modifier.drawBackdrop(
backdrop = safeBackdrop,
backdrop = rememberCombinedBackdrop(safeBackdrop, searchContentBackdrop),
shape = { Capsule() },
effects = {
lens(
10f.dp.toPx(),
14f.dp.toPx(),
10f.dp.toPx() * searchPressProgress,
14f.dp.toPx() * searchPressProgress,
chromaticAberration = true,
)
},
highlight = { Highlight.Default.copy(alpha = 0.72f) },
shadow = { Shadow(alpha = 0.56f) },
innerShadow = { InnerShadow(radius = 8.dp, alpha = 0.52f) },
highlight = { Highlight.Default.copy(alpha = searchPressProgress) },
shadow = { Shadow(alpha = searchPressProgress) },
innerShadow = {
InnerShadow(
radius = 8.dp * searchPressProgress,
alpha = searchPressProgress,
)
},
layerBlock = {
val selectedScale = 1f + (78f / 56f - 1f) * searchPressProgress
scaleX = selectedScale
scaleY = selectedScale
},
onDrawSurface = {
drawRect(
if (LumaColors.isDark) Color.White.copy(alpha = 0.10f)
else Color.Black.copy(alpha = 0.10f)
else Color.Black.copy(alpha = 0.10f),
alpha = 1f - searchPressProgress,
)
drawRect(Color.Black.copy(alpha = 0.03f * searchPressProgress))
},
)
} else {
@@ -396,6 +396,8 @@ fun LiquidGlassPopupOverlay(
expandToRight: Boolean = false,
placeBelowAnchor: Boolean = false,
anchorGap: Dp = 6.dp,
containerColor: Color? = null,
borderColor: Color? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val density = androidx.compose.ui.platform.LocalDensity.current
@@ -449,12 +451,12 @@ fun LiquidGlassPopupOverlay(
)
val currentMenuRect by androidx.compose.runtime.rememberUpdatedState(menuRect)
val shape = RoundedCornerShape(cornerRadius)
val surface = if (GlassTokens.isDark) {
val surface = containerColor ?: if (GlassTokens.isDark) {
Color(0xFF14171D).copy(alpha = 0.42f)
} else {
Color.White.copy(alpha = 0.20f)
}
val border = GlassTokens.glassBorder
val border = borderColor ?: GlassTokens.glassBorder
Box(modifier.fillMaxSize().zIndex(20f)) {
Box(
@@ -494,8 +496,10 @@ fun LiquidGlassPopupOverlay(
shadow = { Shadow(alpha = 0.18f) },
onDrawSurface = { drawRect(surface) },
)
} else {
} else if (containerColor == null) {
Modifier.lumaFallbackGlass(shape, border = false)
} else {
Modifier.background(surface, shape)
}
)
.border(0.7.dp, border, shape)
@@ -3,6 +3,7 @@ package com.example.myapplication.ui.liquidglass
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.spring
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
@@ -11,7 +12,6 @@ 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
import androidx.compose.runtime.LaunchedEffect
@@ -20,22 +20,36 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.example.myapplication.ui.AppTheme
import com.example.myapplication.ui.GlassMode
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.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
@@ -44,20 +58,15 @@ import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow
import com.kyant.backdrop.shadow.Shadow
import com.kyant.shapes.Capsule
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 kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.sign
/** true = the hidden accent-capture layer, false = the visible base layer */
/** true = the hidden blue content-capture layer, false = the visible layer. */
val LocalLiquidBottomTabsGhostLayer = staticCompositionLocalOf { false }
/** Local marker retained for the bottom-tab content layer. */
@Composable
fun LiquidBottomTabs(
selectedTabIndex: () -> Int,
@@ -66,21 +75,16 @@ fun LiquidBottomTabs(
tabsCount: Int,
showSelection: () -> Boolean = { true },
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit
content: @Composable RowScope.() -> Unit,
) {
val isLightTheme = !LumaColors.isDark
val containerColor = Color.Transparent
val neutralTint = if (isLightTheme) {
val accentColor = if (isLightTheme) Color(0xFF0088FF) else Color(0xFF0091FF)
val containerColor = if (isLightTheme) {
Color(0xFFFAFAFA).copy(alpha = 0.40f)
} else {
Color(0xFF121212).copy(alpha = 0.40f)
}
val selectedNeutralTint = if (isLightTheme) {
Color.Black.copy(alpha = 0.10f)
} else {
Color.White.copy(alpha = 0.10f)
}
val liquidFallback = com.example.myapplication.ui.AppTheme.glassMode == com.example.myapplication.ui.GlassMode.Liquid
val liquidFallback = AppTheme.glassMode == GlassMode.Liquid
val fallbackContainerVeil = if (isLightTheme) {
if (liquidFallback) Color(0xFFD7E3F0).copy(alpha = 0.54f)
else Color(0xFFD4D9DF).copy(alpha = 0.78f)
@@ -93,17 +97,16 @@ fun LiquidBottomTabs(
} else {
Color(0xFF26313E).copy(alpha = 0.62f)
}
val useBackdrop = LumaGlassRuntime.useNativeBackdrop && backdrop != null
val nativeBackdrop = backdrop?.takeIf { LumaGlassRuntime.useNativeBackdrop }
val selectionVisible = showSelection()
val tabsBackdrop = rememberLayerBackdrop()
val currentOnTabSelected by rememberUpdatedState(onTabSelected)
BoxWithConstraints(
modifier,
contentAlignment = Alignment.CenterStart
) {
BoxWithConstraints(modifier, contentAlignment = Alignment.CenterStart) {
val density = LocalDensity.current
val tabWidth = with(density) {
(constraints.maxWidth.toFloat() - 8f.dp.toPx()) / tabsCount
}
val offsetAnimation = remember { Animatable(0f) }
val panelOffset by remember(density) {
derivedStateOf {
@@ -126,17 +129,14 @@ fun LiquidBottomTabs(
valueRange = 0f..(tabsCount - 1).toFloat(),
visibilityThreshold = 0.001f,
initialScale = 1f,
pressedScale = 1.06f,
pressedScale = 78f / 56f,
onDragStarted = {},
onDragStopped = {
val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1)
currentIndex = targetIndex
animateToValue(targetIndex.toFloat())
animationScope.launch {
offsetAnimation.animateTo(
0f,
spring(1f, 300f, 0.5f)
)
offsetAnimation.animateTo(0f, spring(1f, 300f, 0.5f))
}
},
onDrag = { _, dragAmount ->
@@ -147,12 +147,13 @@ fun LiquidBottomTabs(
animationScope.launch {
offsetAnimation.snapTo(offsetAnimation.value + dragAmount.x)
}
}
},
)
}
val externalIndex = selectedTabIndex().fastCoerceIn(0, tabsCount - 1)
LaunchedEffect(externalIndex) {
if (externalIndex != currentIndex) {
LaunchedEffect(externalIndex, selectionVisible) {
if (selectionVisible && externalIndex != currentIndex) {
currentIndex = externalIndex
dampedDragAnimation.animateToValue(externalIndex.toFloat())
}
@@ -162,11 +163,24 @@ fun LiquidBottomTabs(
.drop(1)
.collectLatest { index ->
dampedDragAnimation.animateToValue(index.toFloat())
onTabSelected(index)
currentOnTabSelected(index)
}
}
if (!useBackdrop) {
val interactiveHighlight = remember(animationScope, isLtr, tabWidth) {
InteractiveHighlight(
animationScope = animationScope,
position = { size, _ ->
Offset(
if (isLtr) (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset
else size.width - (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset,
size.height / 2f,
)
},
)
}
if (nativeBackdrop == null) {
Box(
Modifier
.graphicsLayer { translationX = panelOffset }
@@ -177,7 +191,7 @@ fun LiquidBottomTabs(
neutralVeil = fallbackContainerVeil,
),
)
if (showSelection()) {
if (selectionVisible) {
Box(
Modifier
.padding(horizontal = 4.dp)
@@ -195,104 +209,133 @@ fun LiquidBottomTabs(
),
)
}
}
if (useBackdrop) {
Box(
Row(
Modifier
.graphicsLayer { translationX = panelOffset }
.then(interactiveHighlight.modifier)
.then(interactiveHighlight.gestureModifier)
.then(dampedDragAnimation.modifier)
.clip(Capsule())
.height(64.dp)
.fillMaxWidth()
.padding(LumaSpacing.xxs),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
} else {
Row(
Modifier
.graphicsLayer { translationX = panelOffset }
.drawBackdrop(
backdrop = backdrop,
backdrop = nativeBackdrop,
shape = { Capsule() },
effects = {
vibrancy()
blur(8f.dp.toPx())
lens(24f.dp.toPx(), 24f.dp.toPx())
},
onDrawSurface = {
drawRect(containerColor)
drawRect(neutralTint)
},
)
.border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule())
.height(64f.dp)
.fillMaxWidth()
.padding(LumaSpacing.xxs)
.clip(Capsule()),
)
}
if (useBackdrop && showSelection()) {
Box(
Modifier
.padding(horizontal = 4f.dp)
.graphicsLayer {
translationX =
if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset
else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset
}
.clip(Capsule())
.drawBackdrop(
backdrop = backdrop,
shape = { Capsule() },
effects = {
lens(
10f.dp.toPx(),
14f.dp.toPx(),
chromaticAberration = true,
)
},
highlight = {
val progress = dampedDragAnimation.pressProgress
Highlight.Default.copy(alpha = 0.56f + progress * 0.44f)
},
shadow = {
val progress = dampedDragAnimation.pressProgress
Shadow(alpha = 0.36f + progress * 0.64f)
},
innerShadow = {
val progress = dampedDragAnimation.pressProgress
InnerShadow(
radius = 8f.dp,
alpha = 0.36f + progress * 0.64f,
)
},
layerBlock = {
scaleX = dampedDragAnimation.scaleX
scaleY = dampedDragAnimation.scaleY
},
onDrawSurface = {
val progress = dampedDragAnimation.pressProgress
drawRect(selectedNeutralTint, alpha = 1f - progress)
drawRect(Color.Black.copy(alpha = 0.03f * progress))
val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress)
scaleX = scale
scaleY = scale
},
onDrawSurface = { drawRect(containerColor) },
)
.height(56f.dp)
.fillMaxWidth(1f / tabsCount)
.alpha(1f),
)
}
Row(
Modifier
.graphicsLayer {
translationX = panelOffset
}
.then(dampedDragAnimation.modifier)
.clip(Capsule())
.height(64f.dp)
.fillMaxWidth()
.padding(LumaSpacing.xxs)
.clip(Capsule()),
verticalAlignment = Alignment.CenterVertically,
content = {
CompositionLocalProvider(
LocalLiquidBottomTabsGhostLayer provides false,
) {
.then(interactiveHighlight.modifier)
.then(interactiveHighlight.gestureModifier)
.then(dampedDragAnimation.modifier)
.border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule())
.height(64.dp)
.fillMaxWidth()
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CompositionLocalProvider(LocalLiquidBottomTabsGhostLayer provides false) {
content()
}
}
)
CompositionLocalProvider(LocalLiquidBottomTabsGhostLayer provides true) {
Row(
Modifier
.clearAndSetSemantics {}
.alpha(0f)
.layerBackdrop(tabsBackdrop)
.graphicsLayer { translationX = panelOffset }
.drawBackdrop(
backdrop = nativeBackdrop,
shape = { Capsule() },
effects = {
val progress = dampedDragAnimation.pressProgress
vibrancy()
blur(8f.dp.toPx())
lens(24f.dp.toPx() * progress, 24f.dp.toPx() * progress)
},
highlight = {
Highlight.Default.copy(alpha = dampedDragAnimation.pressProgress)
},
onDrawSurface = { drawRect(containerColor) },
)
.then(interactiveHighlight.modifier)
.height(56.dp)
.fillMaxWidth()
.padding(horizontal = 4.dp)
.graphicsLayer(colorFilter = ColorFilter.tint(accentColor)),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
}
if (selectionVisible) {
Box(
Modifier
.padding(horizontal = 4.dp)
.graphicsLayer {
translationX =
if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset
else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset
}
.drawBackdrop(
backdrop = rememberCombinedBackdrop(nativeBackdrop, tabsBackdrop),
shape = { Capsule() },
effects = {
val progress = dampedDragAnimation.pressProgress
lens(
10f.dp.toPx() * progress,
14f.dp.toPx() * progress,
chromaticAberration = true,
)
},
highlight = {
Highlight.Default.copy(alpha = dampedDragAnimation.pressProgress)
},
shadow = { Shadow(alpha = dampedDragAnimation.pressProgress) },
innerShadow = {
val progress = dampedDragAnimation.pressProgress
InnerShadow(radius = 8.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(
if (isLightTheme) Color.Black.copy(alpha = 0.10f)
else Color.White.copy(alpha = 0.10f),
alpha = 1f - progress,
)
drawRect(Color.Black.copy(alpha = 0.03f * progress))
},
)
.height(56.dp)
.fillMaxWidth(1f / tabsCount),
)
}
}
}
}
+8
View File
@@ -318,6 +318,14 @@
<string name="server_show_password">Show password</string>
<string name="server_note_label">Note</string>
<string name="server_note_placeholder">Note</string>
<string name="server_alternate_title">Alternate addresses</string>
<string name="server_alternate_subtitle">Add multiple alternate connection addresses</string>
<string name="server_alternate_item">Alternate address %1$d</string>
<string name="server_alternate_name">Name</string>
<string name="server_alternate_name_placeholder">For example: Remote access</string>
<string name="server_alternate_add">Add alternate address</string>
<string name="server_alternate_delete">Delete alternate address</string>
<string name="server_alternate_incomplete">Complete the name, address, and port for each alternate address</string>
<string name="server_saving">Saving…</string>
<string name="server_save">Save</string>
<string name="server_cancel">Cancel</string>
+8
View File
@@ -318,6 +318,14 @@
<string name="server_show_password">显示密码</string>
<string name="server_note_label">备注</string>
<string name="server_note_placeholder">备注</string>
<string name="server_alternate_title">备用地址</string>
<string name="server_alternate_subtitle">可添加多个备用连接地址</string>
<string name="server_alternate_item">备用地址 %1$d</string>
<string name="server_alternate_name">名称</string>
<string name="server_alternate_name_placeholder">例如:外网线路</string>
<string name="server_alternate_add">添加备用地址</string>
<string name="server_alternate_delete">删除备用地址</string>
<string name="server_alternate_incomplete">请完整填写每个备用地址的名称、地址和端口</string>
<string name="server_saving">保存中…</string>
<string name="server_save">保存</string>
<string name="server_cancel">取消</string>
@@ -0,0 +1,56 @@
package com.example.myapplication.data
import org.junit.Assert.assertEquals
import org.junit.Test
class EmbyServerAddressTest {
@Test
fun httpsUrlWithoutPortUses443AndExtractsPath() {
val parsed = parseServerAddressInput(
input = "https://emby.example.com/media/emby",
currentProtocol = "http",
currentPort = "",
currentBasePath = "",
)
assertEquals("https", parsed.protocol)
assertEquals("emby.example.com", parsed.host)
assertEquals("443", parsed.port)
assertEquals("media/emby", parsed.basePath)
}
@Test
fun explicitPortAndHttpProtocolArePreserved() {
val parsed = parseServerAddressInput(
input = "http://192.168.1.8:8096/emby",
currentProtocol = "https",
currentPort = "443",
currentBasePath = "",
)
assertEquals("http", parsed.protocol)
assertEquals("192.168.1.8", parsed.host)
assertEquals("8096", parsed.port)
assertEquals("emby", parsed.basePath)
}
@Test
fun bareHostUsesProtocolSwitchAndBuildsCompleteUrl() {
val parsed = parseServerAddressInput(
input = "emby.example.com/emby",
currentProtocol = "https",
currentPort = "",
currentBasePath = "",
)
val server = EmbyServer(
id = "test",
protocol = parsed.protocol,
host = parsed.host,
port = parsed.port,
basePath = parsed.basePath,
)
assertEquals("https://emby.example.com:443/emby", server.fullUrl)
}
}