From 7aad0982df5dd2c4bebefb6d90f74671455ae681 Mon Sep 17 00:00:00 2001 From: beyond Date: Sun, 30 Aug 2026 17:13:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E4=B8=AD=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E5=92=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../myapplication/data/AppDatabase.java | 18 +- .../myapplication/data/ServerEntity.java | 14 +- .../com/example/myapplication/MainApp.kt | 6 + .../example/myapplication/data/EmbyServer.kt | 34 +- .../myapplication/data/ServerRepository.kt | 86 +++- .../myapplication/navigation/AppNavHost.kt | 4 +- .../myapplication/screens/LibraryScreen.kt | 455 +++++++++++++----- .../myapplication/screens/SearchScreen.kt | 4 +- .../example/myapplication/ui/LiquidGlass.kt | 218 ++++++--- .../myapplication/ui/design/LumaComponents.kt | 9 +- app/src/main/res/values-en/strings.xml | 12 +- app/src/main/res/values/strings.xml | 12 +- .../data/EmbyServerAddressTest.kt | 36 ++ 13 files changed, 656 insertions(+), 252 deletions(-) diff --git a/app/src/main/java/com/example/myapplication/data/AppDatabase.java b/app/src/main/java/com/example/myapplication/data/AppDatabase.java index b1c5cde..1f09df0 100644 --- a/app/src/main/java/com/example/myapplication/data/AppDatabase.java +++ b/app/src/main/java/com/example/myapplication/data/AppDatabase.java @@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase; @Database( entities = {IconLibraryEntity.class, ServerEntity.class}, - version = 7, + version = 9, exportSchema = false ) public abstract class AppDatabase extends RoomDatabase { @@ -90,6 +90,20 @@ public abstract class AppDatabase extends RoomDatabase { } }; + private static final Migration MIGRATION_7_8 = new Migration(7, 8) { + @Override + public void migrate(SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE servers ADD COLUMN activeAddressId TEXT"); + } + }; + + private static final Migration MIGRATION_8_9 = new Migration(8, 9) { + @Override + public void migrate(SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE servers ADD COLUMN lastAccessedAt INTEGER NOT NULL DEFAULT 0"); + } + }; + public static AppDatabase getInstance(Context context) { if (instance == null) { synchronized (AppDatabase.class) { @@ -98,7 +112,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, MIGRATION_6_7).build(); + ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9).build(); } } } diff --git a/app/src/main/java/com/example/myapplication/data/ServerEntity.java b/app/src/main/java/com/example/myapplication/data/ServerEntity.java index 171221e..8def77c 100644 --- a/app/src/main/java/com/example/myapplication/data/ServerEntity.java +++ b/app/src/main/java/com/example/myapplication/data/ServerEntity.java @@ -23,6 +23,8 @@ public class ServerEntity { public String iconUrl; public int isSelected; public String alternateAddresses; + public String activeAddressId; + public long lastAccessedAt; @Ignore public ServerEntity( @@ -37,7 +39,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, "[]", "", 0L); } @Ignore @@ -54,7 +56,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, "[]", "", 0L); } @Ignore @@ -72,7 +74,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, "[]", "", 0L); } public ServerEntity( @@ -89,7 +91,9 @@ public class ServerEntity { String userId, String iconUrl, int isSelected, - String alternateAddresses + String alternateAddresses, + String activeAddressId, + long lastAccessedAt ) { this.id = id; this.name = name; @@ -105,5 +109,7 @@ public class ServerEntity { this.iconUrl = iconUrl == null ? "" : iconUrl; this.isSelected = isSelected; this.alternateAddresses = alternateAddresses == null ? "[]" : alternateAddresses; + this.activeAddressId = activeAddressId == null ? "" : activeAddressId; + this.lastAccessedAt = lastAccessedAt; } } diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt index db7cab5..d1262b4 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainApp.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt @@ -206,6 +206,12 @@ fun MainApp() { onDismissChangeIcon = { changeIconTarget = null }, onPickIcon = { server, iconUrl -> ServerRepository.setRuntimeIcon(server.id, iconUrl) }, onRequestChangeIcon = { server -> changeIconTarget = server }, + onSwitchServerAddress = { serverId, addressId -> + coroutineScope.launch { + ServerRepository.setActiveAddress(serverId, addressId) + ServerRepository.checkServerOnline(serverId) + } + }, onAddIconLibrary = { name, url, icons, isDefault -> val library = IconLibrary( id = "lib_${System.currentTimeMillis()}", diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt index 3adc861..c78bc3d 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt @@ -41,33 +41,21 @@ data class EmbyServer( val authError: String = "", val isSelected: Boolean = false, val alternateAddresses: List = emptyList(), + val activeAddressId: String = "", + val lastAccessedAt: Long = 0L, + val movieCount: Int? = null, + val seriesCount: Int? = null, ) { + val activeAlternateAddress: EmbyServerAddress? + get() = alternateAddresses.firstOrNull { it.id == activeAddressId } + val fullUrl: String - get() { - // Strip protocol prefix from host if user already included it - var cleanHost = host.trim() - val effectiveProtocol = if (protocol.equals("http", ignoreCase = true)) "http" else "https" - if (cleanHost.startsWith("https://", ignoreCase = true)) { - cleanHost = cleanHost.substringAfter("://") - } else if (cleanHost.startsWith("http://", ignoreCase = true)) { - cleanHost = cleanHost.substringAfter("://") - } - // Strip trailing slashes and port if embedded in host - cleanHost = cleanHost.trimEnd('/') - if (port.isBlank()) { - // Check if host already contains a port like "example.com:8096" - val portMatch = Regex("^(.+):(\\d+)$").find(cleanHost) - if (portMatch != null) { - cleanHost = portMatch.groupValues[1] - } - } - val base = if (port.isNotBlank()) "$effectiveProtocol://$cleanHost:$port" else "$effectiveProtocol://$cleanHost" - val segments = listOf(base.trimEnd('/'), basePath.trim().trim('/')).filter { it.isNotBlank() } - return segments.joinToString("/") - } + get() = activeAlternateAddress?.fullUrl ?: buildServerUrl(protocol, host, port, basePath) val displayHost: String - get() = if (port.isNotBlank()) "$host:$port" else host + get() = activeAlternateAddress?.let { address -> + if (address.port.isNotBlank()) "${address.host}:${address.port}" else address.host + } ?: if (port.isNotBlank()) "$host:$port" else host companion object { fun generateId(): String = "server_${UUID.randomUUID()}" diff --git a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt index f06d134..43e3618 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt @@ -79,32 +79,63 @@ object ServerRepository { } suspend fun checkAllServersOnline() { - val currentServers = _servers.value.toList() - for (server in currentServers) { - val reachable = EmbyRepository.checkOnline(server) - if (!reachable) { - updateServerStatus(server.id, false, "无法连接到服务器") - continue - } - val authResult = EmbyRepository.authenticate(server) - if (authResult.success) { - if (server.apiKey != authResult.accessToken) { - val updated = server.copy( - apiKey = authResult.accessToken, - userId = authResult.userId, - ) - updateServer(updated) - } - updateServerStatus(server.id, true, "") + _servers.value.toList().forEach { server -> + checkServerOnline(server.id) + } + } + + suspend fun checkServerOnline(serverId: String) { + val server = _servers.value.find { it.id == serverId } ?: return + val reachable = EmbyRepository.checkOnline(server) + if (!reachable) { + updateServerStatus(server.id, false, "无法连接到服务器") + return + } + val authResult = EmbyRepository.authenticate(server) + if (!authResult.success) { + updateServerStatus(server.id, false, authResult.errorMessage ?: "登录失败") + return + } + + val authenticatedServer = server.copy( + apiKey = authResult.accessToken, + userId = authResult.userId, + ) + if (server.apiKey != authenticatedServer.apiKey || server.userId != authenticatedServer.userId) { + updateServer(authenticatedServer) + } + updateServerStatus(server.id, true, "", System.currentTimeMillis()) + val stats = EmbyRepository.getResourceStats(authenticatedServer).withoutVersions + updateServerMediaCounts(server.id, stats.movieCount, stats.seriesCount) + } + + fun updateServerStatus(serverId: String, isOnline: Boolean, error: String, lastAccessedAt: Long? = null) { + var changedServer: EmbyServer? = null + _servers.value = _servers.value.map { server -> + if (server.id == serverId) { + server.copy( + isOnline = isOnline, + authError = error, + lastAccessedAt = lastAccessedAt ?: server.lastAccessedAt, + ).also { changedServer = it } } else { - updateServerStatus(server.id, false, authResult.errorMessage ?: "登录失败") + server + } + } + if (lastAccessedAt != null) { + changedServer?.let { updated -> + repositoryScope.launch { database?.serverDao()?.insertServer(toEntity(updated)) } } } } - fun updateServerStatus(serverId: String, isOnline: Boolean, error: String) { + private fun updateServerMediaCounts(serverId: String, movieCount: Int, seriesCount: Int) { _servers.value = _servers.value.map { server -> - if (server.id == serverId) server.copy(isOnline = isOnline, authError = error) else server + if (server.id == serverId) { + server.copy(movieCount = movieCount, seriesCount = seriesCount) + } else { + server + } } } @@ -116,6 +147,15 @@ object ServerRepository { reload() } + suspend fun setActiveAddress(serverId: String, addressId: String) { + val server = _servers.value.find { it.id == serverId } ?: return + val validAddressId = addressId.takeIf { candidate -> + candidate.isNotBlank() && server.alternateAddresses.any { it.id == candidate } + }.orEmpty() + if (server.activeAddressId == validAddressId) return + updateServer(server.copy(activeAddressId = validAddressId)) + } + private suspend fun reload(): List { val previousServers = _servers.value.toList() val entities = withContext(Dispatchers.IO) { @@ -129,6 +169,8 @@ object ServerRepository { isOnline = prev?.isOnline ?: true, lastActive = prev?.lastActive ?: "", authError = prev?.authError ?: "", + movieCount = prev?.movieCount, + seriesCount = prev?.seriesCount, ) ) } @@ -186,6 +228,8 @@ object ServerRepository { server.iconUrl, if (server.isSelected) 1 else 0, encodeAlternateAddresses(server.alternateAddresses), + server.activeAddressId, + server.lastAccessedAt, ) } @@ -205,6 +249,8 @@ object ServerRepository { iconUrl = entity.iconUrl ?: "", isSelected = entity.isSelected == 1, alternateAddresses = decodeAlternateAddresses(entity.alternateAddresses), + activeAddressId = entity.activeAddressId ?: "", + lastAccessedAt = entity.lastAccessedAt, ) } diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt index 8bc20ed..4c3f242 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -61,6 +61,7 @@ fun AppNavHost( onDismissChangeIcon: () -> Unit, onPickIcon: (EmbyServer, String) -> Unit, onRequestChangeIcon: (EmbyServer) -> Unit, + onSwitchServerAddress: (String, String) -> Unit, onAddIconLibrary: (String, String, List, Boolean) -> Unit, onUpdateIconLibrary: (IconLibrary) -> Unit, onDeleteIconLibrary: (String) -> Unit, @@ -116,7 +117,7 @@ fun AppNavHost( } composable(AppRoute.Library.pattern) { - var isGrid by rememberSaveable { mutableStateOf(false) } + var isGrid by rememberSaveable { mutableStateOf(true) } LibraryScreen( servers = servers, selectedServer = selectedServer, @@ -128,6 +129,7 @@ fun AppNavHost( onDismissChangeIcon = onDismissChangeIcon, onPickIcon = onPickIcon, onRequestChangeIcon = onRequestChangeIcon, + onSwitchServerAddress = onSwitchServerAddress, onViewToggle = { isGrid = !isGrid }, onAdd = { navigateOnce(AppRoute.ServerEditor.create()) }, onOpenIconLibraryManager = { navigateOnce(AppRoute.IconLibraryManager.pattern) }, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt index 034b512..e0a787f 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -45,6 +45,7 @@ import com.example.myapplication.ui.GlassDialog import com.example.myapplication.ui.GlassSheet import com.example.myapplication.ui.GlassInputField import com.example.myapplication.ui.GlassPopupItem +import com.example.myapplication.ui.LiquidGlassButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -143,7 +144,7 @@ fun LibraryTopBar( iconSize = 21.dp, ) LumaIconButton( - icon = LumaIcon.Library, + icon = LumaIcon.Image, contentDescription = stringResource(Res.string.server_library_manager), onClick = onOpenIconLibraryManager, tint = GlassTokens.text, @@ -166,8 +167,10 @@ fun LibraryTopBar( /* ------------------------------------------------------------------ */ @Composable -private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { +private fun ServerCard(server: EmbyServer, tint: Color, nowMillis: Long, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { var clicked by remember { mutableStateOf(false) } + val activeLineName = server.activeAlternateAddress?.name?.takeIf { it.isNotBlank() } + ?: stringResource(Res.string.server_primary_line) val pressBg by animateColorAsState(targetValue = if (clicked) LumaColors.glassHighlight.copy(alpha = 0.24f) else Color.Transparent, animationSpec = tween(durationMillis = if (clicked) 80 else 200), label = "pb") LaunchedEffect(clicked) { if (clicked) { delay(150); clicked = false } } GlassSurface( @@ -178,13 +181,13 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC ) { clicked = true; onClick() }, cornerRadius = 20.dp, fill = Color.Transparent, - border = Color.Unspecified, + border = GlassTokens.glassBorder, 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)) { + Column(modifier = Modifier.fillMaxWidth().background(pressBg).padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { if (server.iconUrl.isNotBlank()) { LumaAsyncImage(model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(), contentDescription = null, modifier = Modifier.size(46.dp).clip(RoundedCornerShape(14.dp)), contentScale = ContentScale.Crop) } else { @@ -194,16 +197,15 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC } Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = server.name.ifBlank { server.displayHost }, color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - Spacer(Modifier.width(7.dp)) - Box(Modifier.size(8.dp).clip(CircleShape).background(if (server.isOnline) onlineGreen else offlineRed)) - } - if (!server.isOnline && server.authError.isNotBlank()) { - Text(text = server.authError, color = offlineRed, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) - } else { - Text(text = if (server.isOnline) server.lastActive.ifEmpty { stringResource(Res.string.server_online) } else stringResource(Res.string.server_offline), color = GlassTokens.subtext, fontSize = 12.sp) - } + Text(text = server.name.ifBlank { server.displayHost }, color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = activeLineName, + color = GlassTokens.accent, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) if (server.note.isNotBlank()) { Text( text = server.note, @@ -215,7 +217,6 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC ) } } - } Box( Modifier .size(48.dp) @@ -229,6 +230,12 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC ) { LumaIconView(LumaIcon.More, contentDescription = stringResource(Res.string.server_actions), tint = GlassTokens.subtext, modifier = Modifier.size(24.dp)) } + } + ServerStatusLine(server = server, nowMillis = nowMillis, compact = false, modifier = Modifier.padding(top = 8.dp)) + if (!server.isOnline && server.authError.isNotBlank()) { + Text(text = server.authError, color = offlineRed, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp)) + } + ServerMediaCounts(server = server, modifier = Modifier.padding(top = 8.dp)) } } } @@ -238,8 +245,10 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC /* ------------------------------------------------------------------ */ @Composable -private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { +private fun ServerGridCard(server: EmbyServer, tint: Color, nowMillis: Long, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { var clicked by remember { mutableStateOf(false) } + val activeLineName = server.activeAlternateAddress?.name?.takeIf { it.isNotBlank() } + ?: stringResource(Res.string.server_primary_line) val pressBg by animateColorAsState(targetValue = if (clicked) LumaColors.glassHighlight.copy(alpha = 0.24f) else Color.Transparent, animationSpec = tween(durationMillis = if (clicked) 80 else 200), label = "pb") LaunchedEffect(clicked) { if (clicked) { delay(150); clicked = false } } GlassSurface( @@ -250,7 +259,7 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, ) { clicked = true; onClick() }, cornerRadius = 20.dp, fill = Color.Transparent, - border = Color.Unspecified, + border = GlassTokens.glassBorder, shadowElevation = 0.dp, padding = PaddingValues(0.dp), highlight = false, @@ -283,19 +292,11 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, overflow = TextOverflow.Ellipsis, ) Spacer(Modifier.height(3.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(7.dp).clip(CircleShape).background(if (server.isOnline) onlineGreen else offlineRed)) - Spacer(Modifier.width(4.dp)) - if (!server.isOnline && server.authError.isNotBlank()) { - Text(text = server.authError, color = offlineRed, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) - } else { - Text(text = if (server.isOnline) server.lastActive.ifEmpty { stringResource(Res.string.server_online) } else stringResource(Res.string.server_offline), color = GlassTokens.subtext, fontSize = 11.sp) - } - } + Text(text = activeLineName, color = GlassTokens.accent, fontSize = 11.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) } Box( Modifier - .size(48.dp) + .size(36.dp) .onGloballyPositioned(onMorePositioned) .clickable( indication = null, @@ -304,19 +305,24 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, ) { onMoreClick() }, contentAlignment = Alignment.Center, ) { - LumaIconView(LumaIcon.More, contentDescription = stringResource(Res.string.server_actions), tint = GlassTokens.subtext, modifier = Modifier.size(24.dp)) + LumaIconView(LumaIcon.More, contentDescription = stringResource(Res.string.server_actions), tint = GlassTokens.subtext, modifier = Modifier.size(20.dp)) } } - Box(Modifier.height(22.dp)) { + ServerStatusLine(server = server, nowMillis = nowMillis, compact = true, modifier = Modifier.padding(top = 8.dp)) + if (!server.isOnline && server.authError.isNotBlank()) { + Text(text = server.authError, color = offlineRed, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 3.dp)) + } + if (server.note.isNotBlank()) { Text( text = server.note, color = GlassTokens.subtext, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(top = 3.dp), ) } + ServerMediaCounts(server = server, modifier = Modifier.padding(top = 8.dp)) } } } @@ -337,6 +343,7 @@ fun LibraryScreen( onDismissChangeIcon: () -> Unit = {}, onPickIcon: (EmbyServer, String) -> Unit = { _, _ -> }, onRequestChangeIcon: (EmbyServer) -> Unit = {}, + onSwitchServerAddress: (String, String) -> Unit = { _, _ -> }, onViewToggle: () -> Unit = {}, onAdd: () -> Unit = {}, onOpenIconLibraryManager: () -> Unit = {}, @@ -346,9 +353,18 @@ fun LibraryScreen( var popupAnchor by remember { mutableStateOf(null) } var popupVisible by remember { mutableStateOf(false) } var deleteServerTarget by remember { mutableStateOf(null) } + var addressPickerTarget by remember { mutableStateOf(null) } var rootOrigin by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } + var nowMillis by remember { mutableStateOf(System.currentTimeMillis()) } val libraryBackdrop = rememberLumaLayerBackdrop() + LaunchedEffect(Unit) { + while (true) { + delay(60_000L) + nowMillis = System.currentTimeMillis() + } + } + if (onChangeIconTarget != null) { IconPickerDialog( server = onChangeIconTarget, @@ -396,7 +412,7 @@ fun LibraryScreen( val tint = serverTints[index % serverTints.size] if (isGrid) { ServerGridCard( - server = server, tint = tint, + server = server, tint = tint, nowMillis = nowMillis, onClick = { onSelectServer(server.id) }, onChangeIcon = { onRequestChangeIcon(server) }, onDelete = { onDeleteServer(server.id) }, @@ -412,7 +428,7 @@ fun LibraryScreen( ) } else { ServerCard( - server = server, tint = tint, + server = server, tint = tint, nowMillis = nowMillis, onClick = { onSelectServer(server.id) }, onChangeIcon = { onRequestChangeIcon(server) }, onDelete = { onDeleteServer(server.id) }, @@ -453,6 +469,14 @@ fun LibraryScreen( popupServer = null popupAnchor = null }, + height = 200.dp, + autoPlaceAroundAnchor = true, + bottomInset = 86.dp, + containerColor = if (GlassTokens.isDark) { + Color(0xFF141A22).copy(alpha = 0.88f) + } else { + Color(0xFFE5EBF2).copy(alpha = 0.90f) + }, ) { GlassPopupItem( text = stringResource(Res.string.server_edit), @@ -461,9 +485,17 @@ fun LibraryScreen( ) GlassPopupItem( text = stringResource(Res.string.change_icon_action), - icon = LumaIcon.Library, + icon = LumaIcon.Image, onClick = { popupVisible = false; onRequestChangeIcon(currentPopupServer) }, ) + GlassPopupItem( + text = stringResource(Res.string.server_switch_line), + icon = LumaIcon.Network, + onClick = { + popupVisible = false + addressPickerTarget = currentPopupServer + }, + ) GlassPopupItem( text = stringResource(Res.string.icon_lib_delete), icon = LumaIcon.Delete, @@ -490,6 +522,159 @@ fun LibraryScreen( }, ) } + + addressPickerTarget?.let { serverToSwitch -> + ServerAddressPickerDialog( + server = serverToSwitch, + backdrop = libraryBackdrop, + onDismiss = { addressPickerTarget = null }, + onSelect = { addressId -> + onSwitchServerAddress(serverToSwitch.id, addressId) + addressPickerTarget = null + }, + ) + } + } + } +} + +private fun managementDialogSurfaceColor(): Color = if (GlassTokens.isDark) { + Color(0xFF111923).copy(alpha = 0.92f) +} else { + Color(0xFFDDE7F1).copy(alpha = 0.94f) +} + +@Composable +private fun ServerAddressPickerDialog( + server: EmbyServer, + backdrop: Backdrop?, + onDismiss: () -> Unit, + onSelect: (String) -> Unit, +) { + val activeAddressId = server.activeAlternateAddress?.id.orEmpty() + GlassDialog( + backdrop = backdrop, + onDismissRequest = onDismiss, + modifier = Modifier.width(320.dp), + surfaceTint = Color.Transparent, + dimBehind = false, + useBackdropGlass = false, + ) { + Column( + modifier = Modifier.fillMaxWidth() + .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.70f).dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + LumaIconView( + icon = LumaIcon.Network, + contentDescription = null, + tint = GlassTokens.accent, + modifier = Modifier.size(22.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + text = stringResource(Res.string.server_switch_line), + color = GlassTokens.text, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + } + + ServerAddressOption( + name = stringResource(Res.string.server_primary_line), + address = if (server.port.isNotBlank()) "${server.host}:${server.port}" else server.host, + selected = activeAddressId.isBlank(), + onClick = { onSelect("") }, + ) + server.alternateAddresses.forEach { address -> + ServerAddressOption( + name = address.name.ifBlank { stringResource(Res.string.server_alternate_line) }, + address = if (address.port.isNotBlank()) "${address.host}:${address.port}" else address.host, + selected = activeAddressId == address.id, + onClick = { onSelect(address.id) }, + ) + } + } + } +} + +@Composable +private fun ServerStatusLine(server: EmbyServer, nowMillis: Long, compact: Boolean, modifier: Modifier = Modifier) { + val elapsed = (nowMillis - server.lastAccessedAt).coerceAtLeast(0L) + val accessText = when { + server.lastAccessedAt <= 0L -> null + elapsed < 60_000L -> stringResource(Res.string.server_access_just_now) + elapsed < 3_600_000L -> stringResource(Res.string.server_access_minutes_ago, elapsed / 60_000L) + elapsed < 86_400_000L -> stringResource(Res.string.server_access_hours_ago, elapsed / 3_600_000L) + else -> stringResource(Res.string.server_access_days_ago, elapsed / 86_400_000L) + } + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(if (compact) 7.dp else 8.dp).clip(CircleShape).background(if (server.isOnline) onlineGreen else offlineRed)) + Spacer(Modifier.width(5.dp)) + Text( + text = listOfNotNull( + stringResource(if (server.isOnline) Res.string.server_online else Res.string.server_offline), + accessText, + ).joinToString(" · "), + color = GlassTokens.subtext, + fontSize = if (compact) 10.sp else 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ServerMediaCounts(server: EmbyServer, modifier: Modifier = Modifier) { + Text( + text = if (server.movieCount != null && server.seriesCount != null) { + stringResource(Res.string.server_media_counts, server.movieCount, server.seriesCount) + } else { + stringResource(Res.string.server_media_counts_pending) + }, + modifier = modifier, + color = GlassTokens.subtext, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun ServerAddressOption( + name: String, + address: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(if (selected) LumaColors.accentSoft else Color.Transparent) + .border(0.7.dp, if (selected) GlassTokens.accent.copy(alpha = 0.55f) else GlassTokens.glassBorder, RoundedCornerShape(16.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + role = Role.Button, + onClick = onClick, + ) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(Modifier.weight(1f)) { + Text(name, color = GlassTokens.text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) + Text(address, color = GlassTokens.subtext, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + if (selected) { + LumaIconView( + icon = LumaIcon.Check, + contentDescription = stringResource(Res.string.server_current_line), + tint = GlassTokens.accent, + modifier = Modifier.size(18.dp), + ) } } } @@ -598,7 +783,7 @@ internal fun IconLibraryManagerScreenContent( contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { - LumaIconView(LumaIcon.Library, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(32.dp)) + LumaIconView(LumaIcon.Image, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(32.dp)) Text(stringResource(Res.string.icon_lib_empty), color = GlassTokens.subtext, fontSize = 15.sp) Text(stringResource(Res.string.icon_lib_empty_hint), color = GlassTokens.subtext.copy(alpha = 0.75f), fontSize = 12.sp) } @@ -664,9 +849,9 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { Modifier.fillMaxWidth() .lumaGlass( radius = LumaShapes.large, - level = com.example.myapplication.ui.design.LumaGlassLevel.Clear, + level = LumaGlassLevel.Regular, tint = Color.Transparent, - border = false, + borderColor = GlassTokens.glassBorder, shadowElevation = 0.dp, highlight = false, ) @@ -688,7 +873,7 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), contentAlignment = Alignment.Center, ) { - LumaIconView(LumaIcon.Library, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp)) + LumaIconView(LumaIcon.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp)) } Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { @@ -713,7 +898,7 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { library.icons.take(4).forEach { icon -> Box( Modifier.size(34.dp) - .lumaGlass(radius = 9.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp), + .lumaGlass(radius = 9.dp, level = LumaGlassLevel.Clear, tint = Color.Transparent, shadowElevation = 0.dp, highlight = false), contentAlignment = Alignment.Center, ) { LumaAsyncImage( @@ -727,13 +912,13 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { if (library.iconCount > 0 && library.icons.isEmpty()) { Box( Modifier.weight(1f).height(34.dp) - .lumaGlass(radius = 9.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp), + .lumaGlass(radius = 9.dp, level = LumaGlassLevel.Clear, tint = Color.Transparent, shadowElevation = 0.dp, highlight = false), contentAlignment = Alignment.Center, ) { Text(stringResource(Res.string.icon_lib_not_loaded), color = GlassTokens.subtext, fontSize = 11.sp) } } else if (library.icons.isEmpty()) { Box( Modifier.weight(1f).height(34.dp) - .lumaGlass(radius = 9.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp), + .lumaGlass(radius = 9.dp, level = LumaGlassLevel.Clear, tint = Color.Transparent, shadowElevation = 0.dp, highlight = false), contentAlignment = Alignment.Center, ) { Text(stringResource(Res.string.icon_lib_no_icons), color = GlassTokens.subtext, fontSize = 11.sp) } } @@ -742,7 +927,7 @@ private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { Spacer(Modifier.height(11.dp)) Box( Modifier.fillMaxWidth() - .lumaGlass(radius = 12.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp) + .lumaGlass(radius = 12.dp, level = LumaGlassLevel.Clear, tint = Color.Transparent, shadowElevation = 0.dp, highlight = false) .padding(vertical = 7.dp), contentAlignment = Alignment.Center, ) { @@ -771,9 +956,14 @@ private fun ManageIconLibraryDialog( var isRefreshing by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() - GlassDialog(backdrop = backdrop, onDismissRequest = onDismiss) { + GlassDialog( + backdrop = backdrop, + onDismissRequest = onDismiss, + modifier = Modifier.width(320.dp), + surfaceOverlay = managementDialogSurfaceColor(), + ) { Column( - modifier = Modifier.width(300.dp) + modifier = Modifier.fillMaxWidth() .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.78f).dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -785,7 +975,7 @@ private fun ManageIconLibraryDialog( .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), contentAlignment = Alignment.Center, ) { - LumaIconView(LumaIcon.Library, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) + LumaIconView(LumaIcon.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) } Spacer(Modifier.width(10.dp)) Text(text = stringResource(Res.string.icon_lib_manage_dialog_title), fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text) @@ -800,18 +990,15 @@ private fun ManageIconLibraryDialog( } // Editable library metadata - GlassInputField(label = stringResource(Res.string.icon_lib_name_label), value = editName, placeholder = stringResource(Res.string.icon_lib_name_label), onValueChange = { editName = it }) - GlassInputField(label = stringResource(Res.string.icon_lib_url_label), value = editUrl, placeholder = "https://...", onValueChange = { editUrl = it }) + GlassInputField(label = stringResource(Res.string.icon_lib_name_label), value = editName, placeholder = stringResource(Res.string.icon_lib_name_label), onValueChange = { editName = it }, containerTint = Color.Transparent, useBackdropGlass = false) + GlassInputField(label = stringResource(Res.string.icon_lib_url_label), value = editUrl, placeholder = "https://...", onValueChange = { editUrl = it }, containerTint = Color.Transparent, useBackdropGlass = false) // Default library switch Row( Modifier.fillMaxWidth() - .lumaGlass( - radius = 16.dp, - level = LumaGlassLevel.Interactive, - tint = if (setAsDefault) LumaColors.accentSoft else LumaColors.clearTint, - borderColor = if (setAsDefault) GlassTokens.accent.copy(alpha = 0.35f) else LumaColors.glassBorder, - ) + .clip(RoundedCornerShape(16.dp)) + .background(if (setAsDefault) LumaColors.accentSoft else Color.Transparent) + .border(0.7.dp, if (setAsDefault) GlassTokens.accent.copy(alpha = 0.35f) else LumaColors.glassBorder, RoundedCornerShape(16.dp)) .clickable { setAsDefault = !setAsDefault } .padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -837,14 +1024,15 @@ private fun ManageIconLibraryDialog( Text(stringResource(Res.string.icon_lib_preview), fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) LazyVerticalGrid( columns = GridCells.Fixed(5), - modifier = Modifier.heightIn(min = 80.dp, max = 320.dp), + modifier = Modifier.heightIn(min = 80.dp, max = 160.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { items(library.icons) { icon -> Box( modifier = Modifier.aspectRatio(1f) - .lumaGlass(radius = 8.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp) + .clip(RoundedCornerShape(8.dp)) + .border(0.6.dp, GlassTokens.glassBorder, RoundedCornerShape(8.dp)) .padding(2.dp), contentAlignment = Alignment.Center, ) { @@ -862,11 +1050,8 @@ private fun ManageIconLibraryDialog( // Refresh button Box( modifier = Modifier.fillMaxWidth() - .lumaGlass( - radius = 16.dp, - level = LumaGlassLevel.Interactive, - tint = if (isRefreshing) LumaColors.clearTint else LumaColors.regularTint, - ) + .clip(RoundedCornerShape(16.dp)) + .border(0.7.dp, GlassTokens.glassBorder, RoundedCornerShape(16.dp)) .clickable(enabled = !isRefreshing) { isRefreshing = true scope.launch { @@ -892,6 +1077,7 @@ private fun ManageIconLibraryDialog( onClick = onDelete, modifier = Modifier.weight(1f), danger = true, + useBackdropGlass = false, ) GlassButton( text = stringResource(Res.string.icon_lib_save), @@ -903,6 +1089,7 @@ private fun ManageIconLibraryDialog( modifier = Modifier.weight(1f), accent = true, enabled = editName.text.isNotBlank() && editUrl.text.isNotBlank(), + useBackdropGlass = false, ) } } @@ -924,7 +1111,12 @@ private fun AddIconLibraryDialog( val scope = rememberCoroutineScope() val fetchErrorFallback = stringResource(Res.string.icon_lib_fetch_error) - GlassDialog(backdrop = backdrop, onDismissRequest = onDismiss, modifier = Modifier.width(320.dp)) { + GlassDialog( + backdrop = backdrop, + onDismissRequest = onDismiss, + modifier = Modifier.width(320.dp), + surfaceOverlay = managementDialogSurfaceColor(), + ) { Box(Modifier.fillMaxWidth()) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), @@ -936,7 +1128,7 @@ private fun AddIconLibraryDialog( .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), contentAlignment = Alignment.Center, ) { - LumaIconView(LumaIcon.Library, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) + LumaIconView(LumaIcon.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) } Spacer(Modifier.width(12.dp)) Text(text = stringResource(Res.string.icon_lib_add_title), fontWeight = FontWeight.Bold, fontSize = 19.sp, color = GlassTokens.text) @@ -947,17 +1139,16 @@ private fun AddIconLibraryDialog( value = name, placeholder = stringResource(Res.string.icon_lib_name_hint), onValueChange = { name = it }, + containerTint = Color.Transparent, + useBackdropGlass = false, ) // Default library switch Row( Modifier.fillMaxWidth() - .lumaGlass( - radius = 16.dp, - level = LumaGlassLevel.Interactive, - tint = if (isDefault) LumaColors.accentSoft else LumaColors.clearTint, - borderColor = if (isDefault) GlassTokens.accent.copy(alpha = 0.35f) else LumaColors.glassBorder, - ) + .clip(RoundedCornerShape(16.dp)) + .background(if (isDefault) LumaColors.accentSoft else Color.Transparent) + .border(0.7.dp, if (isDefault) GlassTokens.accent.copy(alpha = 0.35f) else LumaColors.glassBorder, RoundedCornerShape(16.dp)) .clickable { isDefault = !isDefault } .padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, @@ -988,29 +1179,27 @@ private fun AddIconLibraryDialog( error = null icons = emptyList() }, + containerTint = Color.Transparent, + useBackdropGlass = false, ) - Box( - modifier = Modifier.fillMaxWidth() - .lumaGlass( - radius = 16.dp, - level = LumaGlassLevel.Interactive, - tint = if (url.text.isNotBlank() && !isLoading) GlassTokens.accent else LumaColors.accentSoft, - borderColor = GlassTokens.accent.copy(alpha = 0.35f), - ) - .clickable(enabled = url.text.isNotBlank() && !isLoading) { - scope.launch { - isLoading = true; error = null - val result = IconLibraryRepository.fetchIcons(url.text) - result.fold( - onSuccess = { items -> icons = items; fetched = true }, - onFailure = { error = fetchErrorFallback } - ) - isLoading = false - } + LiquidGlassButton( + onClick = { + scope.launch { + isLoading = true; error = null + val result = IconLibraryRepository.fetchIcons(url.text.trim()) + result.fold( + onSuccess = { items -> icons = items; fetched = true }, + onFailure = { error = fetchErrorFallback } + ) + isLoading = false } - .padding(vertical = 14.dp), - contentAlignment = Alignment.Center, + }, + modifier = Modifier.fillMaxWidth(), + cornerRadius = 16.dp, + enabled = url.text.isNotBlank() && !isLoading, + fill = GlassTokens.accent.copy(alpha = 0.78f), + useBackdropGlass = false, ) { if (isLoading) { CircularProgressIndicator(modifier = Modifier.size(22.dp), color = Color.White, strokeWidth = 2.5.dp) @@ -1025,19 +1214,21 @@ private fun AddIconLibraryDialog( Text(stringResource(Res.string.icon_lib_preview_count, icons.size), fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) Box( Modifier.fillMaxWidth() - .lumaGlass(radius = 16.dp, level = LumaGlassLevel.Clear) + .clip(RoundedCornerShape(16.dp)) + .border(0.6.dp, GlassTokens.glassBorder, RoundedCornerShape(16.dp)) .padding(8.dp) ) { LazyVerticalGrid( columns = GridCells.Fixed(5), - modifier = Modifier.heightIn(min = 80.dp, max = 320.dp), + modifier = Modifier.height(240.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { items(icons) { icon -> Box( modifier = Modifier.aspectRatio(1f) - .lumaGlass(radius = 12.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp), + .clip(RoundedCornerShape(12.dp)) + .border(0.6.dp, GlassTokens.glassBorder, RoundedCornerShape(12.dp)), contentAlignment = Alignment.Center, ) { LumaAsyncImage( @@ -1058,13 +1249,16 @@ private fun AddIconLibraryDialog( text = stringResource(Res.string.icon_lib_cancel), onClick = onDismiss, modifier = Modifier.weight(1f), + clear = true, + useBackdropGlass = false, ) GlassButton( text = stringResource(Res.string.icon_lib_save), onClick = { onSave(name.text.trim(), url.text.trim(), icons, isDefault); onDismiss() }, modifier = Modifier.weight(1f), accent = true, - enabled = icons.isNotEmpty() && name.text.isNotBlank(), + enabled = name.text.isNotBlank() && url.text.isNotBlank(), + useBackdropGlass = false, ) } } @@ -1115,7 +1309,7 @@ private fun IconPickerDialog( .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), contentAlignment = Alignment.Center, ) { - LumaIconView(LumaIcon.Library, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) + LumaIconView(LumaIcon.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) } Spacer(Modifier.width(12.dp)) Text(text = stringResource(Res.string.icon_picker_title), fontWeight = FontWeight.Bold, fontSize = 19.sp, color = GlassTokens.text) @@ -1194,7 +1388,7 @@ private fun IconPickerDialog( .lumaGlass(radius = 28.dp, level = LumaGlassLevel.Clear, shadowElevation = 0.dp), contentAlignment = Alignment.Center ) { - LumaIconView(LumaIcon.Library, contentDescription = null, tint = GlassTokens.subtext2, modifier = Modifier.size(28.dp)) + LumaIconView(LumaIcon.Image, contentDescription = null, tint = GlassTokens.subtext2, modifier = Modifier.size(28.dp)) } Spacer(Modifier.height(14.dp)) Text(text = stringResource(Res.string.icon_picker_no_lib), color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.Medium) @@ -1441,6 +1635,9 @@ private fun ServerFormContent( basePath = address.basePath.text.trim().trim('/'), ) }, + activeAddressId = initial?.activeAddressId?.takeIf { activeId -> + normalizedAlternates.any { it.id == activeId } + }.orEmpty(), ) onConfirm(server) { error -> isSaving = false @@ -1498,6 +1695,41 @@ private fun ServerFormContent( focusRequester = portFocusRequester, ) } + ServerFormInput( + label = stringResource(Res.string.server_username_label), + value = username, + placeholder = stringResource(Res.string.server_username_placeholder), + onValueChange = { username = it }, + isError = usernameError, + errorMessage = if (usernameError) stringResource(Res.string.server_username_error) else null, + focusRequester = usernameFocusRequester, + ) + ServerFormInput( + label = stringResource(Res.string.server_password_label), + value = password, + placeholder = stringResource(Res.string.server_password_placeholder), + onValueChange = { password = it }, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + trailingContent = { + 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(label = stringResource(Res.string.server_note_label), value = note, placeholder = stringResource(Res.string.server_note_placeholder), onValueChange = { note = it }) Text( text = stringResource(Res.string.server_alternate_title), color = GlassTokens.text, @@ -1628,41 +1860,6 @@ private fun ServerFormContent( }, modifier = Modifier.fillMaxWidth(), ) - ServerFormInput( - label = stringResource(Res.string.server_username_label), - value = username, - placeholder = stringResource(Res.string.server_username_placeholder), - onValueChange = { username = it }, - isError = usernameError, - errorMessage = if (usernameError) stringResource(Res.string.server_username_error) else null, - focusRequester = usernameFocusRequester, - ) - ServerFormInput( - label = stringResource(Res.string.server_password_label), - value = password, - placeholder = stringResource(Res.string.server_password_placeholder), - onValueChange = { password = it }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - trailingContent = { - 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(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)) } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt index c354b7a..15e37ec 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt @@ -68,6 +68,7 @@ fun SearchTopBar( onValueChange = onQueryChange, placeholder = stringResource(Res.string.search_hint), onGearClick = onGearClick, + useBackdropGlass = false, ) } } @@ -147,6 +148,7 @@ fun SearchScreen( icon = LumaIcon.Search, title = stringResource(Res.string.search_no_result_title), description = stringResource(Res.string.search_no_result_prefix) + query.trim() + stringResource(Res.string.search_no_result_suffix), + showIconBackground = false, ) } else -> LazyVerticalGrid( @@ -180,7 +182,7 @@ private fun SearchEmptyState(title: String, subtitle: String) { .padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()), contentAlignment = Alignment.Center, ) { - LumaEmptyState(icon = LumaIcon.Search, title = title, description = subtitle) + LumaEmptyState(icon = LumaIcon.Search, title = title, description = subtitle, showIconBackground = false) } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index 29540fa..2c12562 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -1,5 +1,6 @@ package com.example.myapplication.ui +import android.view.WindowManager import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring @@ -21,6 +22,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues @@ -51,7 +53,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawBehind @@ -82,11 +83,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.window.DialogWindowProvider import kotlinx.coroutines.delay import kotlin.math.roundToInt import androidx.compose.ui.zIndex import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.asPaddingValues @@ -157,6 +160,7 @@ fun LiquidGlassBackground(modifier: Modifier = Modifier) { @Composable fun GlassSurface( modifier: Modifier = Modifier, + backdrop: Backdrop? = LocalGlassBackdrop.current, cornerRadius: androidx.compose.ui.unit.Dp = 22.dp, fill: Color = GlassTokens.glassFill, border: Color = GlassTokens.glassBorder, @@ -170,7 +174,7 @@ fun GlassSurface( Box( modifier .lumaGlass( - backdrop = LocalGlassBackdrop.current, + backdrop = backdrop, radius = cornerRadius, level = LumaGlassLevel.Regular, border = border != Color.Unspecified, @@ -313,25 +317,31 @@ fun GlassSearchField( placeholder: String, onGearClick: () -> Unit = {}, modifier: Modifier = Modifier, + useBackdropGlass: Boolean = true, ) { val backdrop = LocalGlassBackdrop.current - var focused by remember { mutableStateOf(false) } - val borderColor by animateColorAsState( - targetValue = if (focused) GlassTokens.accent.copy(alpha = 0.72f) else GlassTokens.glassBorder, - animationSpec = tween(LumaMotion.fast), - label = "searchFieldFocus", - ) + val borderColor = GlassTokens.glassBorder Row( modifier .fillMaxWidth() - .lumaGlass( - backdrop = backdrop, - radius = LumaShapes.large, - level = LumaGlassLevel.Regular, - borderColor = borderColor, - shadowElevation = 0.dp, + .height(52.dp) + .then( + if (useBackdropGlass) { + Modifier.lumaGlass( + backdrop = backdrop, + radius = LumaShapes.large, + level = LumaGlassLevel.Regular, + borderColor = borderColor, + shadowElevation = 0.dp, + ) + } else { + val shape = RoundedCornerShape(LumaShapes.large) + Modifier.clip(shape) + .background(Color.Transparent, shape) + .border(0.7.dp, borderColor, shape) + } ) - .padding(horizontal = 14.dp, vertical = 13.dp), + .padding(start = 14.dp, end = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { LumaIconView( @@ -349,7 +359,7 @@ fun GlassSearchField( color = GlassTokens.text, fontSize = 15.sp, ), - modifier = Modifier.weight(1f).onFocusChanged { focused = it.isFocused }, + modifier = Modifier.weight(1f), decorationBox = { inner -> Box { if (value.isEmpty()) { @@ -359,18 +369,25 @@ fun GlassSearchField( } }, ) - if (value.isNotEmpty()) { - Box( - Modifier - .size(48.dp) + Box( + Modifier + .size(48.dp) + .then( + if (value.isNotEmpty()) { + Modifier .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, role = Role.Button, onClick = { onValueChange("") }, - ), - contentAlignment = Alignment.Center, - ) { + ) + } else { + Modifier + } + ), + contentAlignment = Alignment.Center, + ) { + if (value.isNotEmpty()) { LumaIconView( icon = LumaIcon.Close, contentDescription = stringResource(Res.string.clear_search), @@ -395,7 +412,9 @@ fun LiquidGlassPopupOverlay( height: Dp = 152.dp, expandToRight: Boolean = false, placeBelowAnchor: Boolean = false, - anchorGap: Dp = 6.dp, + autoPlaceAroundAnchor: Boolean = false, + anchorGap: Dp = 3.dp, + bottomInset: Dp = 0.dp, containerColor: Color? = null, borderColor: Color? = null, content: @Composable ColumnScope.() -> Unit, @@ -429,27 +448,6 @@ fun LiquidGlassPopupOverlay( val menuHeight = initialSize + (height - initialSize) * heightProgress val contentProgress = ((rawProgress - 0.34f) / 0.66f).coerceIn(0f, 1f) val cornerRadius = initialSize / 2f + (18.dp - initialSize / 2f) * rawProgress - val initialSizePx = with(density) { initialSize.toPx() } - val anchorCenterX = anchorBounds.center.x - rootOrigin.x - val anchorCenterY = anchorBounds.center.y - rootOrigin.y - val menuWidthPx = with(density) { menuWidth.toPx() } - val menuLeft = if (expandToRight) { - anchorCenterX - initialSizePx / 2f - } else { - anchorCenterX + initialSizePx / 2f - menuWidthPx - } - val menuTop = if (placeBelowAnchor) { - anchorBounds.bottom - rootOrigin.y + with(density) { anchorGap.toPx() } - } else { - anchorCenterY - initialSizePx / 2f - } - val menuRect = Rect( - menuLeft, - menuTop, - menuLeft + menuWidthPx, - menuTop + with(density) { menuHeight.toPx() }, - ) - val currentMenuRect by androidx.compose.runtime.rememberUpdatedState(menuRect) val shape = RoundedCornerShape(cornerRadius) val surface = containerColor ?: if (GlassTokens.isDark) { Color(0xFF14171D).copy(alpha = 0.42f) @@ -458,7 +456,46 @@ fun LiquidGlassPopupOverlay( } val border = borderColor ?: GlassTokens.glassBorder - Box(modifier.fillMaxSize().zIndex(20f)) { + BoxWithConstraints(modifier = modifier.fillMaxSize().zIndex(20f)) { + val containerWidthPx = with(density) { maxWidth.toPx() } + val containerHeightPx = with(density) { maxHeight.toPx() } + val bottomInsetPx = with(density) { bottomBarContentPadding(bottomInset).calculateBottomPadding().toPx() } + val anchorLeft = anchorBounds.left - rootOrigin.x + val anchorTop = anchorBounds.top - rootOrigin.y + val anchorRight = anchorBounds.right - rootOrigin.x + val anchorBottom = anchorBounds.bottom - rootOrigin.y + val menuWidthPx = with(density) { menuWidth.toPx() } + val menuHeightPx = with(density) { menuHeight.toPx() } + val gapPx = with(density) { anchorGap.toPx() } + val safeBottomPx = containerHeightPx - bottomInsetPx + val initialSizePx = with(density) { initialSize.toPx() } + val anchorCenterX = (anchorLeft + anchorRight) / 2f + val anchorCenterY = (anchorTop + anchorBottom) / 2f + val menuLeft = if (autoPlaceAroundAnchor) { + val expandRight = (containerWidthPx - anchorRight) >= anchorLeft + if (expandRight) anchorRight + gapPx else anchorLeft - gapPx - menuWidthPx + } else if (expandToRight) { + anchorCenterX - initialSizePx / 2f + } else { + anchorCenterX + initialSizePx / 2f - menuWidthPx + } + val menuTop = if (autoPlaceAroundAnchor) { + val openBelow = (safeBottomPx - anchorBottom) >= anchorTop + if (openBelow) anchorBottom + gapPx else anchorTop - gapPx - menuHeightPx + } else if (placeBelowAnchor) { + anchorBottom + gapPx + } else { + anchorCenterY - initialSizePx / 2f + } + val menuLeftClamped = menuLeft.coerceIn(0f, (containerWidthPx - menuWidthPx).coerceAtLeast(0f)) + val menuTopClamped = menuTop.coerceIn(0f, (containerHeightPx - menuHeightPx - bottomInsetPx).coerceAtLeast(0f)) + val menuRect = Rect( + menuLeftClamped, + menuTopClamped, + menuLeftClamped + menuWidthPx, + menuTopClamped + menuHeightPx, + ) + val currentMenuRect by androidx.compose.runtime.rememberUpdatedState(menuRect) Box( Modifier .fillMaxSize() @@ -470,7 +507,7 @@ fun LiquidGlassPopupOverlay( ) Box( Modifier - .offset { IntOffset(menuLeft.roundToInt(), menuTop.roundToInt()) } + .offset { IntOffset(menuLeftClamped.roundToInt(), menuTopClamped.roundToInt()) } .width(menuWidth) .height(menuHeight) .clip(shape) @@ -546,6 +583,7 @@ fun LiquidGlassButton( cornerRadius: Dp = 14.dp, enabled: Boolean = true, fill: Color = GlassTokens.glassFillStrong, + useBackdropGlass: Boolean = true, content: @Composable RowScope.() -> Unit, ) { val backdrop = LocalGlassBackdrop.current @@ -565,12 +603,20 @@ fun LiquidGlassButton( scaleY = scale alpha = if (enabled) 1f else 0.52f } - .lumaGlass( - backdrop = backdrop, - radius = cornerRadius, - level = LumaGlassLevel.Interactive, - tint = fill, - borderColor = GlassTokens.glassBorder, + .then( + if (useBackdropGlass) { + Modifier.lumaGlass( + backdrop = backdrop, + radius = cornerRadius, + level = LumaGlassLevel.Interactive, + tint = fill, + borderColor = GlassTokens.glassBorder, + ) + } else { + Modifier.clip(shape) + .background(fill, shape) + .border(0.7.dp, GlassTokens.glassBorder, shape) + } ) .clickable(enabled = enabled, indication = null, interactionSource = interactionSource, onClick = onClick) .padding(horizontal = 16.dp, vertical = 11.dp), @@ -588,10 +634,13 @@ fun GlassButton( enabled: Boolean = true, accent: Boolean = false, danger: Boolean = false, + clear: Boolean = false, + useBackdropGlass: Boolean = true, ) { val fill = when { danger -> GlassTokens.danger.copy(alpha = 0.84f) accent -> GlassTokens.accent.copy(alpha = 0.78f) + clear -> Color.Transparent else -> GlassTokens.glassFillStrong } LiquidGlassButton( @@ -599,6 +648,7 @@ fun GlassButton( modifier = modifier, enabled = enabled, fill = fill, + useBackdropGlass = useBackdropGlass, ) { Text( text = text, @@ -615,10 +665,19 @@ fun GlassDialog( backdrop: Backdrop? = null, modifier: Modifier = Modifier, surfaceTint: Color? = null, + surfaceOverlay: Color? = null, blurRadius: Dp? = null, + dimBehind: Boolean = true, + useBackdropGlass: Boolean = true, content: @Composable () -> Unit, ) { Dialog(onDismissRequest = onDismissRequest) { + if (!dimBehind) { + val dialogWindow = (LocalView.current.parent as? DialogWindowProvider)?.window + SideEffect { + dialogWindow?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + } + } val dialogCtx = LocalContext.current val localizedCtx = remember(AppLanguage.language) { AppLanguage.getLocalizedContext(dialogCtx) } val glassBackdrop = backdrop ?: LocalGlassBackdrop.current @@ -628,15 +687,28 @@ fun GlassDialog( ) { Box( modifier - .width(IntrinsicSize.Min) - .lumaGlass( - backdrop = glassBackdrop, - radius = 24.dp, - level = LumaGlassLevel.Strong, - tint = surfaceTint, - borderColor = GlassTokens.glassBorder, - shadowElevation = 10.dp, - blurOverride = blurRadius, + .then( + if (useBackdropGlass) { + Modifier.lumaGlass( + backdrop = glassBackdrop, + radius = 24.dp, + level = LumaGlassLevel.Strong, + tint = surfaceTint, + borderColor = GlassTokens.glassBorder, + shadowElevation = 10.dp, + blurOverride = blurRadius, + ) + } else { + val shape = RoundedCornerShape(24.dp) + Modifier.clip(shape) + .background(surfaceTint ?: Color.Transparent, shape) + .border(0.7.dp, GlassTokens.glassBorder, shape) + } + ) + .then( + surfaceOverlay?.let { + Modifier.background(it, RoundedCornerShape(24.dp)) + } ?: Modifier, ) .padding(20.dp), ) { @@ -732,6 +804,8 @@ fun GlassInputField( isError: Boolean = false, errorMessage: String? = null, focusRequester: FocusRequester? = null, + containerTint: Color = GlassTokens.glassFillSoft, + useBackdropGlass: Boolean = true, ) { val backdrop = LocalGlassBackdrop.current Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { @@ -740,12 +814,18 @@ fun GlassInputField( Box( Modifier.fillMaxWidth() .heightIn(min = 48.dp) - .lumaGlass( - backdrop = backdrop, - radius = 12.dp, - level = LumaGlassLevel.Clear, - tint = GlassTokens.glassFillSoft, - border = false, + .then( + if (useBackdropGlass) { + Modifier.lumaGlass( + backdrop = backdrop, + radius = 12.dp, + level = LumaGlassLevel.Clear, + tint = containerTint, + border = false, + ) + } else { + Modifier.clip(shape).background(containerTint, shape) + } ) .border(0.7.dp, if (isError) GlassTokens.danger else GlassTokens.glassBorder, shape) .padding(horizontal = 14.dp, vertical = 12.dp), diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt index 87ffd5e..3419e00 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt @@ -596,6 +596,7 @@ fun LumaEmptyState( modifier: Modifier = Modifier, actionLabel: String? = null, onAction: (() -> Unit)? = null, + showIconBackground: Boolean = true, ) { Column( modifier, @@ -605,7 +606,13 @@ fun LumaEmptyState( Box( Modifier .size(72.dp) - .lumaGlass(radius = 24.dp, level = LumaGlassLevel.Clear), + .then( + if (showIconBackground) { + Modifier.lumaGlass(radius = 24.dp, level = LumaGlassLevel.Clear) + } else { + Modifier + } + ), contentAlignment = Alignment.Center, ) { LumaIconView(icon, modifier = Modifier.size(30.dp), tint = LumaColors.textTertiary) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 51cae5e..1cbe6f8 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -258,7 +258,7 @@ Icon Library Manager Add No icon libraries yet - Tap top-left to add + Tap top-right to add Icons not loaded No icons Preview · Edit @@ -334,7 +334,17 @@ Actions Edit Change Icon + Switch Line + Primary Line + Alternate Line + Current line Just now + Accessed just now + Accessed %1$d min ago + Accessed %1$d hr ago + Accessed %1$d days ago + Movies %1$d · Shows %2$d + Movies -- · Shows -- Toggle View Icon Library Add Server diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f28c373..be0c68e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -258,7 +258,7 @@ 图标库管理 添加 还没有图标库 - 点击左上角添加 + 点击右上角添加 图标未加载 暂无图标 预览 · 修改 @@ -334,7 +334,17 @@ 操作 编辑 更换图标 + 切换线路 + 主线路 + 备用线路 + 当前线路 刚刚 + 刚刚访问 + %1$d 分钟前访问 + %1$d 小时前访问 + %1$d 天前访问 + 电影 %1$d · 节目 %2$d + 电影 -- · 节目 -- 视图切换 图标库管理 添加服务器 diff --git a/app/src/test/kotlin/com/example/myapplication/data/EmbyServerAddressTest.kt b/app/src/test/kotlin/com/example/myapplication/data/EmbyServerAddressTest.kt index 29139c4..7da9feb 100644 --- a/app/src/test/kotlin/com/example/myapplication/data/EmbyServerAddressTest.kt +++ b/app/src/test/kotlin/com/example/myapplication/data/EmbyServerAddressTest.kt @@ -53,4 +53,40 @@ class EmbyServerAddressTest { assertEquals("https://emby.example.com:443/emby", server.fullUrl) } + + @Test + fun selectedAlternateAddressDrivesUrlAndDisplayHost() { + val alternate = EmbyServerAddress( + id = "remote", + name = "Remote", + protocol = "http", + host = "remote.example.com", + port = "8096", + basePath = "emby", + ) + val server = EmbyServer( + id = "test", + host = "home.example.com", + port = "443", + alternateAddresses = listOf(alternate), + activeAddressId = alternate.id, + ) + + assertEquals("http://remote.example.com:8096/emby", server.fullUrl) + assertEquals("remote.example.com:8096", server.displayHost) + } + + @Test + fun unknownAlternateAddressFallsBackToPrimary() { + val server = EmbyServer( + id = "test", + protocol = "https", + host = "home.example.com", + port = "443", + activeAddressId = "missing", + ) + + assertEquals("https://home.example.com:443", server.fullUrl) + assertEquals("home.example.com:443", server.displayHost) + } }