修改服务器中相关功能调整和优化

This commit is contained in:
beyond committed 2026-08-30 17:13:57 +08:00
1 parent e33dcf533c
commit 7aad0982df
13 files changed
+656 -252

No files matched your search

@@ -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();
}
}
}
@@ -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;
}
}
@@ -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()}",
@@ -41,33 +41,21 @@ data class EmbyServer(
val authError: String = "",
val isSelected: Boolean = false,
val alternateAddresses: List<EmbyServerAddress> = 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()}"
@@ -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<EmbyServer> {
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,
)
}
@@ -61,6 +61,7 @@ fun AppNavHost(
onDismissChangeIcon: () -> Unit,
onPickIcon: (EmbyServer, String) -> Unit,
onRequestChangeIcon: (EmbyServer) -> Unit,
onSwitchServerAddress: (String, String) -> Unit,
onAddIconLibrary: (String, String, List<IconItem>, 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) },
@@ -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<androidx.compose.ui.geometry.Rect?>(null) }
var popupVisible by remember { mutableStateOf(false) }
var deleteServerTarget by remember { mutableStateOf<EmbyServer?>(null) }
var addressPickerTarget by remember { mutableStateOf<EmbyServer?>(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))
}
@@ -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)
}
}
@@ -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),
@@ -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)
+11 -1
View File
@@ -258,7 +258,7 @@
<string name="icon_lib_manager_title">Icon Library Manager</string>
<string name="icon_lib_add">Add</string>
<string name="icon_lib_empty">No icon libraries yet</string>
<string name="icon_lib_empty_hint">Tap top-left to add</string>
<string name="icon_lib_empty_hint">Tap top-right to add</string>
<string name="icon_lib_not_loaded">Icons not loaded</string>
<string name="icon_lib_no_icons">No icons</string>
<string name="icon_lib_preview_edit">Preview · Edit</string>
@@ -334,7 +334,17 @@
<string name="server_actions">Actions</string>
<string name="server_edit">Edit</string>
<string name="change_icon_action">Change Icon</string>
<string name="server_switch_line">Switch Line</string>
<string name="server_primary_line">Primary Line</string>
<string name="server_alternate_line">Alternate Line</string>
<string name="server_current_line">Current line</string>
<string name="server_just_now">Just now</string>
<string name="server_access_just_now">Accessed just now</string>
<string name="server_access_minutes_ago">Accessed %1$d min ago</string>
<string name="server_access_hours_ago">Accessed %1$d hr ago</string>
<string name="server_access_days_ago">Accessed %1$d days ago</string>
<string name="server_media_counts">Movies %1$d · Shows %2$d</string>
<string name="server_media_counts_pending">Movies -- · Shows --</string>
<string name="view_toggle">Toggle View</string>
<string name="server_library_manager">Icon Library</string>
<string name="add_server_action">Add Server</string>
+11 -1
View File
@@ -258,7 +258,7 @@
<string name="icon_lib_manager_title">图标库管理</string>
<string name="icon_lib_add">添加</string>
<string name="icon_lib_empty">还没有图标库</string>
<string name="icon_lib_empty_hint">点击左上角添加</string>
<string name="icon_lib_empty_hint">点击右上角添加</string>
<string name="icon_lib_not_loaded">图标未加载</string>
<string name="icon_lib_no_icons">暂无图标</string>
<string name="icon_lib_preview_edit">预览 · 修改</string>
@@ -334,7 +334,17 @@
<string name="server_actions">操作</string>
<string name="server_edit">编辑</string>
<string name="change_icon_action">更换图标</string>
<string name="server_switch_line">切换线路</string>
<string name="server_primary_line">主线路</string>
<string name="server_alternate_line">备用线路</string>
<string name="server_current_line">当前线路</string>
<string name="server_just_now">刚刚</string>
<string name="server_access_just_now">刚刚访问</string>
<string name="server_access_minutes_ago">%1$d 分钟前访问</string>
<string name="server_access_hours_ago">%1$d 小时前访问</string>
<string name="server_access_days_ago">%1$d 天前访问</string>
<string name="server_media_counts">电影 %1$d · 节目 %2$d</string>
<string name="server_media_counts_pending">电影 -- · 节目 --</string>
<string name="view_toggle">视图切换</string>
<string name="server_library_manager">图标库管理</string>
<string name="add_server_action">添加服务器</string>
@@ -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)
}
}