Files
lumaplayer/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt
T

1525 lines
83 KiB
Kotlin

package com.example.myapplication.screens
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator
import com.example.myapplication.ui.GlassButton
import com.example.myapplication.ui.GlassDialog
import com.example.myapplication.ui.GlassInputField
import com.example.myapplication.ui.GlassPopup
import com.example.myapplication.ui.GlassPopupItem
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.CompositionLocalProvider
import com.example.myapplication.ui.AppLanguage
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.res.stringResource
import com.example.myapplication.R as Res
import top.yukonga.miuix.kmp.basic.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.ViewAgenda
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.MoreHoriz
import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import com.example.myapplication.data.EmbyServer
import com.example.myapplication.data.IconItem
import com.example.myapplication.data.IconLibrary
import com.example.myapplication.data.IconLibraryRepository
import com.example.myapplication.ui.GlassSurface
import com.example.myapplication.ui.GlassTokens
import com.example.myapplication.ui.LiquidGlassTopBar
import com.example.myapplication.ui.bottomBarContentPadding
import coil.compose.AsyncImage
import coil.request.ImageRequest
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val serverTints = listOf(
Color(0xFF9CC4FF), Color(0xFFA8E6C0), Color(0xFFD6B8FF), Color(0xFFFFB8D0),
)
private val onlineGreen = Color(0xFF34C759)
private val offlineRed = Color(0xFFFF3B30)
@Composable
fun LibraryTopBar(
isGrid: Boolean,
onViewToggle: () -> Unit,
onAdd: () -> Unit,
onOpenIconLibraryManager: () -> Unit,
modifier: Modifier = Modifier,
) {
LiquidGlassTopBar(modifier) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = "\u8D44\u6E90\u5E93", color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.size(38.dp).clip(CircleShape).background(GlassTokens.glassFill).border(1.dp, GlassTokens.glassBorder, CircleShape).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onViewToggle() }, contentAlignment = Alignment.Center) {
Icon(imageVector = if (isGrid) Icons.Filled.GridView else Icons.Filled.ViewAgenda, contentDescription = "\u89C6\u56FE\u5207\u6362", tint = GlassTokens.text, modifier = Modifier.size(21.dp))
}
Box(Modifier.size(38.dp).clip(CircleShape).background(GlassTokens.glassFill).border(1.dp, GlassTokens.glassBorder, CircleShape).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onOpenIconLibraryManager() }, contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Outlined.PhotoLibrary, contentDescription = "\u56FE\u6807\u5E93\u7BA1\u7406", tint = GlassTokens.text, modifier = Modifier.size(21.dp))
}
Box(Modifier.size(38.dp).clip(CircleShape).background(GlassTokens.glassFill).border(1.dp, GlassTokens.glassBorder, CircleShape).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onAdd() }, contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Outlined.Add, contentDescription = "\u6DFB\u52A0\u670D\u52A1\u5668", tint = GlassTokens.text, modifier = Modifier.size(21.dp))
}
}
}
}
}
/* ------------------------------------------------------------------ */
/* Icon Library Section */
/* ------------------------------------------------------------------ */
@Composable
private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit) {
var showPopup by remember { mutableStateOf(false) }
var clicked by remember { mutableStateOf(false) }
val pressBg by animateColorAsState(targetValue = if (clicked) Color.White.copy(alpha = 0.18f) else Color.Transparent, animationSpec = tween(durationMillis = if (clicked) 80 else 200), label = "pb")
LaunchedEffect(clicked) { if (clicked) { delay(150); clicked = false } }
GlassSurface(modifier = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { clicked = true; onClick() }, fill = tint.copy(alpha = 0.50f), cornerRadius = 20.dp, padding = PaddingValues(0.dp)) {
Row(modifier = Modifier.fillMaxWidth().background(pressBg).padding(14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
if (server.iconUrl.isNotBlank()) {
AsyncImage(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 {
Box(Modifier.size(46.dp).clip(RoundedCornerShape(14.dp)).background(Brush.linearGradient(listOf(tint, tint.copy(alpha = 0.7f)))), contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp))
}
}
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 { "\u5728\u7EBF" } else "\u79BB\u7EBF", color = GlassTokens.subtext, fontSize = 12.sp)
}
if (server.note.isNotBlank()) {
Text(
text = server.note,
color = GlassTokens.subtext,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
Box {
Icon(imageVector = Icons.Outlined.MoreHoriz, contentDescription = "\u64CD\u4F5C", tint = GlassTokens.subtext, modifier = Modifier.size(32.dp).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { showPopup = true }.padding(4.dp))
GlassPopup(expanded = showPopup, onDismissRequest = { showPopup = false }) { GlassPopupItem(text = "\u7F16\u8F91", icon = Icons.Outlined.Edit, onClick = { showPopup = false; onEdit() }); GlassPopupItem(text = "\u66F4\u6362\u56FE\u6807", icon = Icons.Outlined.Image, onClick = { showPopup = false; onChangeIcon() }); GlassPopupItem(text = "\u5220\u9664", icon = Icons.Outlined.Delete, tint = offlineRed, onClick = { showPopup = false; onDelete() }) }
}
}
}
}
/* ------------------------------------------------------------------ */
/* Icon Library Section */
/* ------------------------------------------------------------------ */
@Composable
private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit) {
var showPopup by remember { mutableStateOf(false) }
var clicked by remember { mutableStateOf(false) }
val pressBg by animateColorAsState(targetValue = if (clicked) Color.White.copy(alpha = 0.18f) else Color.Transparent, animationSpec = tween(durationMillis = if (clicked) 80 else 200), label = "pb")
LaunchedEffect(clicked) { if (clicked) { delay(150); clicked = false } }
GlassSurface(modifier = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { clicked = true; onClick() }, fill = tint.copy(alpha = 0.50f), cornerRadius = 20.dp, padding = PaddingValues(0.dp)) {
Column(modifier = Modifier.fillMaxWidth().background(pressBg).padding(14.dp), horizontalAlignment = Alignment.Start) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (server.iconUrl.isNotBlank()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(),
contentDescription = null,
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(14.dp)),
contentScale = ContentScale.Crop,
)
} else {
Box(
Modifier.size(48.dp).clip(RoundedCornerShape(14.dp)).background(Brush.linearGradient(listOf(tint, tint.copy(alpha = 0.7f)))),
contentAlignment = Alignment.Center,
) {
Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp))
}
}
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
Text(
text = server.name.ifBlank { server.displayHost },
color = GlassTokens.text,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
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 { "\u5728\u7EBF" } else "\u79BB\u7EBF", color = GlassTokens.subtext, fontSize = 11.sp)
}
}
}
Box {
Icon(imageVector = Icons.Outlined.MoreHoriz, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(28.dp).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { showPopup = true }.padding(4.dp))
GlassPopup(expanded = showPopup, onDismissRequest = { showPopup = false }) { GlassPopupItem(text = "\u7F16\u8F91", icon = Icons.Outlined.Edit, onClick = { showPopup = false; onEdit() }); GlassPopupItem(text = "\u66F4\u6362\u56FE\u6807", icon = Icons.Outlined.Image, onClick = { showPopup = false; onChangeIcon() }); GlassPopupItem(text = "\u5220\u9664", icon = Icons.Outlined.Delete, tint = offlineRed, onClick = { showPopup = false; onDelete() }) }
}
}
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),
)
}
}
}
}
/* ------------------------------------------------------------------ */
/* Icon Library Section */
/* ------------------------------------------------------------------ */
@Composable
fun LibraryScreen(
servers: List<EmbyServer>,
selectedServer: EmbyServer? = null,
onSelectServer: (String) -> Unit = {},
onAddServer: (EmbyServer, (String?) -> Unit) -> Unit = { _, _ -> },
onDeleteServer: (String) -> Unit = {},
onUpdateServer: (EmbyServer, (String?) -> Unit) -> Unit = { _, _ -> },
showAddDialog: Boolean = false,
onDismissAddDialog: () -> Unit = {},
isGrid: Boolean = false,
onChangeIconTarget: EmbyServer? = null,
onDismissChangeIcon: () -> Unit = {},
onPickIcon: (EmbyServer, String) -> Unit = { _, _ -> },
onRequestChangeIcon: (EmbyServer) -> Unit = {},
) {
var editingServer by remember { mutableStateOf<EmbyServer?>(null) }
if (showAddDialog) {
AddServerDialog(onDismiss = onDismissAddDialog, onConfirm = { srv, onResult ->
onAddServer(srv, onResult)
})
}
if (editingServer != null) {
AddServerDialog(
initial = editingServer,
onDismiss = { editingServer = null },
onConfirm = { updated, onResult ->
onUpdateServer(updated, onResult)
},
)
}
if (onChangeIconTarget != null) {
IconPickerDialog(
server = onChangeIconTarget,
onDismiss = onDismissChangeIcon,
onPick = { srv, url -> onPickIcon(srv, url); onDismissChangeIcon() },
)
}
LazyVerticalGrid(
columns = if (isGrid) GridCells.Fixed(2) else GridCells.Fixed(1),
modifier = Modifier.fillMaxSize().padding(horizontal = if (isGrid) 16.dp else 0.dp),
contentPadding = PaddingValues(top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 10.dp),
horizontalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 0.dp),
) {
// Servers section
items(servers.size) { index ->
val server = servers[index]
val tint = serverTints[index % serverTints.size]
if (isGrid) {
ServerGridCard(
server = server, tint = tint,
onClick = { onSelectServer(server.id) },
onChangeIcon = { onRequestChangeIcon(server) },
onDelete = { onDeleteServer(server.id) },
onEdit = { editingServer = server },
)
} else {
ServerCard(
server = server, tint = tint,
onClick = { onSelectServer(server.id) },
onChangeIcon = { onRequestChangeIcon(server) },
onDelete = { onDeleteServer(server.id) },
onEdit = { editingServer = server },
)
}
}
}
}
/* ------------------------------------------------------------------ */
/* Icon Library Section */
/* ------------------------------------------------------------------ */
@Composable
fun IconLibraryManagerScreen(
libraries: List<IconLibrary>,
modifier: Modifier = Modifier,
onBack: () -> Unit = {},
onAddIconLibrary: (String, String, List<IconItem>, Boolean) -> Unit = { _, _, _, _ -> },
onUpdateIconLibrary: (IconLibrary) -> Unit = { _ -> },
onDeleteIconLibrary: (String) -> Unit = {},
onRefreshIconLibrary: (String) -> Unit = {},
) {
var showAddDialog by remember { mutableStateOf(false) }
var editingLibraryId by remember { mutableStateOf<String?>(null) }
val editingLibrary = editingLibraryId?.let { id -> libraries.find { it.id == id } }
Column(modifier.fillMaxSize()) {
LiquidGlassTopBar {
Row(
Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.width(86.dp)) {
Row(
Modifier.clip(RoundedCornerShape(20.dp))
.background(GlassTokens.accent.copy(alpha = 0.15f))
.border(1.dp, GlassTokens.accent.copy(alpha = 0.38f), RoundedCornerShape(20.dp))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { showAddDialog = true }
.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(Icons.Outlined.Add, contentDescription = null, tint = GlassTokens.accent, modifier = Modifier.size(17.dp))
Text("\u6DFB\u52A0", color = GlassTokens.accent, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
}
}
Box(Modifier.weight(1f), contentAlignment = Alignment.Center) {
Text("\u56FE\u6807\u5E93\u7BA1\u7406", color = GlassTokens.text, fontSize = 19.sp, fontWeight = FontWeight.Bold)
}
Box(Modifier.width(86.dp), contentAlignment = Alignment.CenterEnd) {
Box(
Modifier.size(34.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.42f))
.border(1.dp, Color.White.copy(alpha = 0.55f), CircleShape)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onBack() },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Close, contentDescription = "\u8FD4\u56DE", tint = GlassTokens.text, modifier = Modifier.size(19.dp))
}
}
}
}
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 16.dp, bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (libraries.isEmpty()) {
item(span = { GridItemSpan(2) }) {
Box(
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(22.dp))
.background(Color.White.copy(alpha = 0.45f))
.border(0.5.dp, Color.White.copy(alpha = 0.58f), RoundedCornerShape(22.dp))
.padding(vertical = 54.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Outlined.Image, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(32.dp))
Text("\u8FD8\u6CA1\u6709\u56FE\u6807\u5E93", color = GlassTokens.subtext, fontSize = 15.sp)
Text("\u70B9\u51FB\u5DE6\u4E0A\u89D2\u6DFB\u52A0", color = GlassTokens.subtext.copy(alpha = 0.75f), fontSize = 12.sp)
}
}
}
} else {
items(libraries) { library ->
IconLibraryManagerCard(library = library, onClick = { editingLibraryId = library.id })
}
}
}
}
if (showAddDialog) {
AddIconLibraryDialog(
onDismiss = { showAddDialog = false },
onSave = { name, url, icons, isDefault ->
onAddIconLibrary(name, url, icons, isDefault)
showAddDialog = false
},
)
}
if (editingLibrary != null) {
ManageIconLibraryDialog(
library = editingLibrary,
onDismiss = { editingLibraryId = null },
onUpdate = { updated ->
onUpdateIconLibrary(updated)
editingLibraryId = null
},
onDelete = {
onDeleteIconLibrary(editingLibrary.id)
editingLibraryId = null
},
onRefresh = { onRefreshIconLibrary(editingLibrary.id) },
)
}
}
@Composable
private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) {
Box(
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(
Brush.verticalGradient(
0f to Color.White.copy(alpha = 0.74f),
1f to Color.White.copy(alpha = 0.50f),
)
)
.border(0.5.dp, Color.White.copy(alpha = 0.62f), RoundedCornerShape(20.dp))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(14.dp),
) {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
Modifier.size(38.dp).clip(RoundedCornerShape(12.dp))
.background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp))
}
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Text(library.name, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (library.isDefault) {
Box(
Modifier.clip(RoundedCornerShape(50))
.background(GlassTokens.accent.copy(alpha = 0.14f))
.border(0.5.dp, GlassTokens.accent.copy(alpha = 0.35f), RoundedCornerShape(50))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(stringResource(Res.string.icon_default_badge), color = GlassTokens.accent, fontSize = 10.sp, fontWeight = FontWeight.SemiBold)
}
}
}
Text("${library.iconCount} \u4E2A\u56FE\u6807", color = GlassTokens.subtext, fontSize = 11.sp)
}
}
Spacer(Modifier.height(11.dp))
Row(horizontalArrangement = Arrangement.spacedBy((-5).dp), verticalAlignment = Alignment.CenterVertically) {
library.icons.take(4).forEach { icon ->
Box(
Modifier.size(34.dp).clip(RoundedCornerShape(9.dp))
.background(Color.White.copy(alpha = 0.55f))
.border(0.5.dp, Color.White.copy(alpha = 0.60f), RoundedCornerShape(9.dp)),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
contentDescription = icon.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(6.dp)),
)
}
}
if (library.iconCount > 0 && library.icons.isEmpty()) {
Box(
Modifier.weight(1f).height(34.dp).clip(RoundedCornerShape(9.dp))
.background(Color.White.copy(alpha = 0.30f)),
contentAlignment = Alignment.Center,
) { Text("\u56fe\u6807\u672a\u52a0\u8f7d", color = GlassTokens.subtext, fontSize = 11.sp) }
} else if (library.icons.isEmpty()) {
Box(
Modifier.weight(1f).height(34.dp).clip(RoundedCornerShape(9.dp))
.background(Color.White.copy(alpha = 0.30f)),
contentAlignment = Alignment.Center,
) { Text("\u6682\u65E0\u56FE\u6807", color = GlassTokens.subtext, fontSize = 11.sp) }
} else if (library.iconCount > 4) {
Box(
Modifier.size(34.dp).clip(RoundedCornerShape(9.dp))
.background(GlassTokens.accent.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) { Text("+${library.iconCount - 4}", color = GlassTokens.accent, fontSize = 10.sp, fontWeight = FontWeight.Bold) }
}
}
Spacer(Modifier.height(11.dp))
Box(
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
.background(Color.White.copy(alpha = 0.35f))
.border(0.5.dp, Color.White.copy(alpha = 0.48f), RoundedCornerShape(12.dp))
.padding(vertical = 7.dp),
contentAlignment = Alignment.Center,
) {
Text("\u9884\u89C8 \u00B7 \u4FEE\u6539", color = GlassTokens.text, fontSize = 12.sp, fontWeight = FontWeight.Medium)
}
}
}
}
@Composable
private fun IconLibrarySection(
libraries: List<IconLibrary>,
isGrid: Boolean,
onClick: (IconLibrary) -> Unit,
onManage: (IconLibrary) -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("\u56FE\u6807\u5E93", fontSize = 15.sp, fontWeight = FontWeight.Bold, color = GlassTokens.text)
Text("${libraries.size} \u4E2A", fontSize = 13.sp, color = GlassTokens.subtext)
}
if (isGrid) {
// Grid layout for icon libraries — use Column+Row to avoid nested scrolling
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
libraries.chunked(2).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
row.forEach { lib ->
IconLibraryCard(
library = lib,
modifier = Modifier.weight(1f),
onClick = { onClick(lib) },
onLongClick = { onManage(lib) },
)
}
// Fill remaining space if odd number
if (row.size < 2) {
Spacer(Modifier.weight(1f))
}
}
}
}
} else {
// List layout for icon libraries — use Row to avoid nested scrolling
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
libraries.forEach { lib ->
IconLibraryCard(
library = lib,
modifier = Modifier.width(140.dp),
onClick = { onClick(lib) },
onLongClick = { onManage(lib) },
)
}
}
}
}
}
@Composable
private fun IconLibraryCard(
library: IconLibrary,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(18.dp))
.background(
Brush.verticalGradient(
0f to Color.White.copy(alpha = 0.72f),
1f to Color.White.copy(alpha = 0.48f),
)
)
.border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(18.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(12.dp),
) {
Column {
// Icon preview row
Row(
horizontalArrangement = Arrangement.spacedBy((-6).dp),
verticalAlignment = Alignment.CenterVertically,
) {
library.icons.take(3).forEach { icon ->
Box(
Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))
.background(Brush.verticalGradient(listOf(Color(0xFFFF6B9D).copy(alpha = 0.3f), Color(0xFFFF8A65).copy(alpha = 0.3f))))
.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
contentDescription = icon.name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(24.dp).clip(RoundedCornerShape(6.dp)),
)
}
}
if (library.iconCount > 3) {
Box(
Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))
.background(Brush.verticalGradient(listOf(Color(0xFF9B9B9B).copy(alpha = 0.2f), Color(0xFF9B9B9B).copy(alpha = 0.1f))))
.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Text("+${library.iconCount - 3}", fontSize = 10.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
}
}
}
Spacer(Modifier.height(8.dp))
Text(library.name, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = GlassTokens.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${library.iconCount} \u4E2A\u56FE\u6807", fontSize = 11.sp, color = GlassTokens.subtext)
Spacer(Modifier.height(6.dp))
// Manage button
Box(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp))
.background(Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.12f), GlassTokens.accent.copy(alpha = 0.06f))))
.border(0.5.dp, GlassTokens.accent.copy(alpha = 0.2f), RoundedCornerShape(10.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onLongClick,
)
.padding(vertical = 6.dp),
contentAlignment = Alignment.Center,
) {
Text("\u7BA1\u7406", fontSize = 12.sp, fontWeight = FontWeight.Medium, color = GlassTokens.accent)
}
}
}
}
/* ------------------------------------------------------------------ */
/* Icon Library Detail Dialog — browse icons */
/* ------------------------------------------------------------------ */
@Composable
private fun IconLibraryDetailDialog(library: IconLibrary, onDismiss: () -> Unit) {
Dialog(onDismissRequest = onDismiss) {
val dialogCtx = LocalContext.current
val localizedCtx = remember(AppLanguage.language) { AppLanguage.getLocalizedContext(dialogCtx) }
CompositionLocalProvider(LocalContext provides localizedCtx) {
Box(
Modifier
.width(340.dp)
.shadow(24.dp, RoundedCornerShape(28.dp), ambientColor = Color.White.copy(alpha = 0.18f), spotColor = Color.Black.copy(alpha = 0.05f))
.clip(RoundedCornerShape(28.dp))
.background(
Brush.verticalGradient(0f to Color.White.copy(alpha = 0.88f), 0.5f to Color.White.copy(alpha = 0.75f), 1f to Color.White.copy(alpha = 0.65f))
)
.border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(28.dp))
.padding(20.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Header
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
.background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(library.name, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text)
Text("${library.iconCount} \u4E2A\u56FE\u6807", fontSize = 12.sp, color = GlassTokens.subtext)
}
}
if (library.iconCount > 0 && library.icons.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
.border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
.padding(vertical = 36.dp),
contentAlignment = Alignment.Center,
) { Text("\u56fe\u6807\u672a\u52a0\u8f7d", color = GlassTokens.subtext, fontSize = 14.sp) }
} else if (library.icons.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
.border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
.padding(vertical = 36.dp),
contentAlignment = Alignment.Center,
) { Text("\u6682\u65E0\u56FE\u6807", color = GlassTokens.subtext, fontSize = 14.sp) }
} else {
Box(
Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
.border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
.padding(8.dp)
) {
LazyVerticalGrid(
columns = GridCells.Fixed(5),
modifier = Modifier.heightIn(min = 120.dp, max = 400.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(library.icons) { icon ->
Box(
modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(10.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.55f), 1f to Color.White.copy(alpha = 0.35f)))
.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
.padding(4.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
contentDescription = icon.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)),
)
}
}
}
}
}
// Close button
Box(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f)))
.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
.clickable { onDismiss() }
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) { Text("\u5173\u95ED", color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) }
}
}
}
}
}
/* ------------------------------------------------------------------ */
/* Manage Icon Library Dialog — rename, refresh, delete */
/* ------------------------------------------------------------------ */
@Composable
private fun ManageIconLibraryDialog(
library: IconLibrary,
onDismiss: () -> Unit,
onUpdate: (IconLibrary) -> Unit,
onDelete: () -> Unit,
onRefresh: () -> Unit,
) {
var editName by remember { mutableStateOf(TextFieldValue(library.name)) }
var editUrl by remember { mutableStateOf(TextFieldValue(library.sourceUrl)) }
var setAsDefault by remember { mutableStateOf(library.isDefault) }
var isRefreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
GlassDialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier.width(300.dp)
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.78f).dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Header
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
.background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
Spacer(Modifier.width(10.dp))
Text(text = "\u7BA1\u7406\u56FE\u6807\u5E93", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text)
}
// Info
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column {
Text("\u56FE\u6807\u6570\u91CF", fontSize = 12.sp, color = GlassTokens.subtext)
Text("${library.iconCount}", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = GlassTokens.text)
}
}
// Editable library metadata
GlassInputField(label = "\u56FE\u6807\u5E93\u540D\u79F0", value = editName, placeholder = "\u56FE\u6807\u5E93\u540D\u79F0", onValueChange = { editName = it })
GlassInputField(label = "\u56FE\u6807\u5E93\u5730\u5740 (JSON)", value = editUrl, placeholder = "https://...", onValueChange = { editUrl = it })
// Default library switch
Row(
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(if (setAsDefault) GlassTokens.accent.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.35f))
.border(0.5.dp, if (setAsDefault) GlassTokens.accent.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp))
.clickable { setAsDefault = !setAsDefault }
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(stringResource(Res.string.set_default_library), color = GlassTokens.text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(Res.string.set_default_library_sub), color = GlassTokens.subtext, fontSize = 11.sp)
}
Box(
Modifier.size(width = 42.dp, height = 25.dp)
.clip(CircleShape)
.background(if (setAsDefault) GlassTokens.accent else Color.White.copy(alpha = 0.65f))
.border(0.5.dp, if (setAsDefault) Color.Transparent else Color.White.copy(alpha = 0.75f), CircleShape)
.padding(3.dp),
contentAlignment = if (setAsDefault) Alignment.CenterEnd else Alignment.CenterStart,
) {
Box(Modifier.size(19.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.92f)))
}
}
// Icon preview
if (library.icons.isNotEmpty()) {
Text("\u56FE\u6807\u9884\u89C8", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
LazyVerticalGrid(
columns = GridCells.Fixed(6),
modifier = Modifier.heightIn(min = 80.dp, max = 180.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(library.icons) { icon ->
Box(
modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(8.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.55f), 1f to Color.White.copy(alpha = 0.35f)))
.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
.padding(2.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
contentDescription = icon.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(6.dp)),
)
}
}
}
}
// Refresh button
Box(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.50f), 1f to Color.White.copy(alpha = 0.30f)))
.border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp))
.clickable(enabled = !isRefreshing) {
isRefreshing = true
scope.launch {
onRefresh()
delay(500)
isRefreshing = false
}
}
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
if (isRefreshing) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = GlassTokens.accent, strokeWidth = 2.dp)
} else {
Text("\u5237\u65B0\u56FE\u6807", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = GlassTokens.accent)
}
}
// Save / Delete row
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GlassButton(
text = "\u5220\u9664",
onClick = onDelete,
modifier = Modifier.weight(1f),
danger = true,
)
GlassButton(
text = "\u4FDD\u5B58",
onClick = {
if (editName.text.isNotBlank() && editUrl.text.isNotBlank()) {
onUpdate(library.copy(name = editName.text.trim(), sourceUrl = editUrl.text.trim(), isDefault = setAsDefault))
}
},
modifier = Modifier.weight(1f),
accent = true,
enabled = editName.text.isNotBlank() && editUrl.text.isNotBlank(),
)
}
}
}
}
@Composable
private fun AddIconLibraryDialog(
onDismiss: () -> Unit,
onSave: (String, String, List<IconItem>, Boolean) -> Unit = { _, _, _, _ -> }
) {
var name by remember { mutableStateOf(TextFieldValue("")) }
var url by remember { mutableStateOf(TextFieldValue("")) }
var isDefault by remember { mutableStateOf(false) }
var icons by remember { mutableStateOf<List<IconItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var fetched by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Dialog(onDismissRequest = onDismiss) {
val dialogCtx = LocalContext.current
val localizedCtx = remember(AppLanguage.language) { AppLanguage.getLocalizedContext(dialogCtx) }
CompositionLocalProvider(LocalContext provides localizedCtx) {
Box(
Modifier
.width(320.dp)
.shadow(24.dp, RoundedCornerShape(28.dp), ambientColor = Color.White.copy(alpha = 0.18f), spotColor = Color.Black.copy(alpha = 0.05f))
.clip(RoundedCornerShape(28.dp))
.background(
Brush.verticalGradient(
0f to Color.White.copy(alpha = 0.88f),
0.5f to Color.White.copy(alpha = 0.75f),
1f to Color.White.copy(alpha = 0.65f),
)
)
.border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(28.dp))
.padding(20.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
.background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
Spacer(Modifier.width(12.dp))
Text(text = "\u589E\u52A0\u56FE\u6807\u5E93", fontWeight = FontWeight.Bold, fontSize = 19.sp, color = GlassTokens.text)
}
Text("\u56FE\u6807\u5E93\u540D\u79F0", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
Box(
Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.50f), 1f to Color.White.copy(alpha = 0.30f)))
.border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp))
.padding(horizontal = 16.dp, vertical = 14.dp),
) {
androidx.compose.foundation.text.BasicTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 16.sp),
modifier = Modifier.fillMaxWidth(),
cursorBrush = androidx.compose.ui.graphics.SolidColor(GlassTokens.accent),
decorationBox = { inner ->
Box(Modifier.fillMaxWidth().height(24.dp)) {
if (name.text.isEmpty()) Text("\u4F8B\u5982: Emby \u56FE\u6807\u5E93", color = GlassTokens.subtext, fontSize = 16.sp)
inner()
}
}
)
}
// Default library switch
Row(
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(if (isDefault) GlassTokens.accent.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.35f))
.border(0.5.dp, if (isDefault) GlassTokens.accent.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp))
.clickable { isDefault = !isDefault }
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(stringResource(Res.string.set_default_library), color = GlassTokens.text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Text(stringResource(Res.string.set_default_library_sub), color = GlassTokens.subtext, fontSize = 11.sp)
}
Box(
Modifier.size(width = 42.dp, height = 25.dp)
.clip(CircleShape)
.background(if (isDefault) GlassTokens.accent else Color.White.copy(alpha = 0.65f))
.border(0.5.dp, if (isDefault) Color.Transparent else Color.White.copy(alpha = 0.75f), CircleShape)
.padding(3.dp),
contentAlignment = if (isDefault) Alignment.CenterEnd else Alignment.CenterStart,
) {
Box(Modifier.size(19.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.92f)))
}
}
Text("\u56FE\u6807\u5E93\u5730\u5740 (JSON)", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
Box(
Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.50f), 1f to Color.White.copy(alpha = 0.30f)))
.border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp))
.padding(horizontal = 16.dp, vertical = 14.dp),
) {
androidx.compose.foundation.text.BasicTextField(
value = url,
onValueChange = { url = it; fetched = false; error = null; icons = emptyList() },
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 16.sp),
modifier = Modifier.fillMaxWidth(),
cursorBrush = androidx.compose.ui.graphics.SolidColor(GlassTokens.accent),
decorationBox = { inner ->
Box(Modifier.fillMaxWidth().height(24.dp)) {
if (url.text.isEmpty()) Text("https://...", color = GlassTokens.subtext, fontSize = 16.sp)
inner()
}
}
)
}
Box(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
.background(
if (url.text.isNotBlank() && !isLoading)
Brush.verticalGradient(listOf(GlassTokens.accent, Color(0xFF0A6DD4)))
else
Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.4f), GlassTokens.accent.copy(alpha = 0.3f)))
)
.then(if (url.text.isNotBlank() && !isLoading) Modifier.border(0.5.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) else Modifier)
.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 = { e -> error = e.message ?: "\u83B7\u53D6\u5931\u8D25" }
)
isLoading = false
}
}
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), color = Color.White, strokeWidth = 2.5.dp)
} else {
Text("\u83B7\u53D6\u9884\u89C8", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
if (error != null) Text(text = error!!, color = offlineRed, fontSize = 13.sp)
if (icons.isNotEmpty()) {
Text("\u9884\u89C8 (${icons.size} \u4E2A\u56FE\u6807)", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium)
Box(
Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
.border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp))
.padding(8.dp)
) {
LazyVerticalGrid(
columns = GridCells.Fixed(5),
modifier = Modifier.heightIn(min = 80.dp, max = 320.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(icons) { icon ->
Box(
modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(12.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.55f), 1f to Color.White.copy(alpha = 0.35f)))
.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
contentDescription = icon.name,
modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.Crop,
)
}
}
}
}
Text("\u2713 \u5DF2\u52A0\u8F7D ${icons.size} \u4E2A\u56FE\u6807", fontSize = 12.sp, color = onlineGreen)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
modifier = Modifier.weight(1f).clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f)))
.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
.clickable { onDismiss() }
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) { Text("\u53D6\u6D88", color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) }
Box(
modifier = Modifier.weight(1f).clip(RoundedCornerShape(16.dp))
.background(
if (icons.isNotEmpty() && name.text.isNotBlank())
Brush.verticalGradient(listOf(GlassTokens.accent, Color(0xFF0A6DD4)))
else
Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.4f), GlassTokens.accent.copy(alpha = 0.3f)))
)
.then(if (icons.isNotEmpty() && name.text.isNotBlank()) Modifier.border(0.5.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) else Modifier)
.clickable(enabled = icons.isNotEmpty() && name.text.isNotBlank()) { onSave(name.text.trim(), url.text.trim(), icons, isDefault); onDismiss() }
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) { Text("\u4FDD\u5B58", color = if (icons.isNotEmpty() && name.text.isNotBlank()) Color.White else Color.White.copy(alpha = 0.6f), fontSize = 15.sp, fontWeight = FontWeight.SemiBold) }
}
}
}
}
}
}
/* ------------------------------------------------------------------ */
/* Icon Library Section */
/* ------------------------------------------------------------------ */
@Composable
private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: (EmbyServer, String) -> Unit) {
var allIcons by remember { mutableStateOf<List<IconItem>>(emptyList()) }
var query by remember { mutableStateOf(TextFieldValue("")) }
var isLoading by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val filteredIcons = remember(allIcons, query) {
val keyword = query.text.trim()
if (keyword.isEmpty()) allIcons else allIcons.filter { icon ->
icon.name.contains(keyword, ignoreCase = true)
}
}
LaunchedEffect(Unit) {
scope.launch {
isLoading = true
allIcons = IconLibraryRepository.getPickerIcons()
isLoading = false
}
}
Dialog(onDismissRequest = onDismiss) {
val dialogCtx = LocalContext.current
val localizedCtx = remember(AppLanguage.language) { AppLanguage.getLocalizedContext(dialogCtx) }
CompositionLocalProvider(LocalContext provides localizedCtx) {
Box(
Modifier
.width(320.dp)
.shadow(24.dp, RoundedCornerShape(28.dp), ambientColor = Color.White.copy(alpha = 0.18f), spotColor = Color.Black.copy(alpha = 0.05f))
.clip(RoundedCornerShape(28.dp))
.background(
Brush.verticalGradient(
0f to Color.White.copy(alpha = 0.88f),
0.5f to Color.White.copy(alpha = 0.75f),
1f to Color.White.copy(alpha = 0.65f),
)
)
.border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(28.dp))
.padding(20.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.80f).dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
.background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
Spacer(Modifier.width(12.dp))
Text(text = "\u9009\u62E9\u56FE\u6807", fontWeight = FontWeight.Bold, fontSize = 19.sp, color = GlassTokens.text)
}
if (!isLoading && allIcons.isNotEmpty()) {
Box(
Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp))
.background(Color.White.copy(alpha = 0.42f))
.border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(14.dp))
.padding(horizontal = 12.dp, vertical = 9.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Outlined.Search,
contentDescription = null,
tint = GlassTokens.subtext,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Box(Modifier.weight(1f)) {
if (query.text.isEmpty()) {
Text(
text = "\u641C\u7D22\u56FE\u6807\u540D\u79F0",
color = GlassTokens.subtext,
fontSize = 14.sp,
)
}
BasicTextField(
value = query,
onValueChange = { query = it },
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 14.sp),
modifier = Modifier.fillMaxWidth(),
cursorBrush = androidx.compose.ui.graphics.SolidColor(GlassTokens.accent),
)
}
if (query.text.isNotEmpty()) {
Spacer(Modifier.width(8.dp))
Icon(
Icons.Outlined.Close,
contentDescription = "\u6E05\u9664\u641C\u7D22",
tint = GlassTokens.subtext,
modifier = Modifier
.size(18.dp)
.clickable { query = TextFieldValue("") },
)
}
}
}
}
if (isLoading) {
Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(28.dp), color = GlassTokens.accent, strokeWidth = 2.5.dp)
}
} else if (allIcons.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().weight(1f).clip(RoundedCornerShape(20.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
.border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(20.dp))
.padding(vertical = 36.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.size(56.dp).clip(CircleShape)
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f)))
.border(0.5.dp, Color.White.copy(alpha = 0.45f), CircleShape),
contentAlignment = Alignment.Center
) {
Icon(Icons.Outlined.Image, contentDescription = null, tint = GlassTokens.subtext2, modifier = Modifier.size(28.dp))
}
Spacer(Modifier.height(14.dp))
Text(text = "\u6682\u65E0\u56FE\u6807\u5E93", color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(4.dp))
Text(text = "\u8BF7\u5148\u5728\u56FE\u6807\u5E93\u7BA1\u7406\u4E2D\u6DFB\u52A0\u56FE\u6807\u5E93", color = GlassTokens.subtext, fontSize = 13.sp)
}
}
} else {
Text("\u5DF2\u52A0\u8F7D ${allIcons.size} \u4E2A\u56FE\u6807\uFF0C\u70B9\u51FB\u9009\u62E9", fontSize = 13.sp, color = GlassTokens.subtext)
Box(
Modifier.fillMaxWidth().weight(1f).clip(RoundedCornerShape(20.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f)))
.border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(20.dp))
.padding(8.dp)
) {
LazyVerticalGrid(
columns = GridCells.Fixed(5),
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(filteredIcons) { icon ->
val isSelected = server.iconUrl == icon.url
Box(
modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(12.dp))
.background(
if (isSelected)
Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.2f), GlassTokens.accent.copy(alpha = 0.1f)))
else
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.55f), Color.White.copy(alpha = 0.35f)))
)
.border(if (isSelected) 1.5.dp else 0.5.dp, if (isSelected) GlassTokens.accent else Color.White.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
.clickable { onPick(server, icon.url) },
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(),
contentDescription = icon.name,
modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.Crop,
)
if (isSelected) {
Box(
Modifier.align(Alignment.TopEnd).padding(3.dp).size(18.dp).clip(CircleShape)
.background(Brush.verticalGradient(listOf(GlassTokens.accent, Color(0xFF0A6DD4))))
.border(0.5.dp, Color.White.copy(alpha = 0.4f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.GridView, contentDescription = null, tint = Color.White, modifier = Modifier.size(11.dp))
}
}
}
}
}
}
if (filteredIcons.isEmpty()) {
Box(
Modifier.fillMaxWidth().height(90.dp).clip(RoundedCornerShape(20.dp))
.background(Color.White.copy(alpha = 0.24f))
.border(0.5.dp, Color.White.copy(alpha = 0.35f), RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center,
) {
Text(
text = "\u672A\u627E\u5230\u5339\u914D\u7684\u56FE\u6807",
color = GlassTokens.subtext,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
}
}
}
Box(
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp))
.background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f)))
.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
.clickable { onDismiss() }
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) { Text(text = "\u5173\u95ED", color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) }
}
}
}
}
}
/* ------------------------------------------------------------------ */
/* Icon Library Section */
/* ------------------------------------------------------------------ */
@Composable
private fun AddServerDialog(initial: EmbyServer? = null, onDismiss: () -> Unit, onConfirm: (EmbyServer, (String?) -> Unit) -> Unit) {
var name by remember { mutableStateOf(TextFieldValue(initial?.name ?: "")) }
var host by remember { mutableStateOf(TextFieldValue(initial?.host ?: "")) }
var port by remember { mutableStateOf(TextFieldValue(initial?.port ?: "")) }
var basePath by remember { mutableStateOf(TextFieldValue(initial?.basePath ?: "")) }
var username by remember { mutableStateOf(TextFieldValue(initial?.username ?: "")) }
var password by remember { mutableStateOf(TextFieldValue(initial?.password ?: "")) }
var note by remember { mutableStateOf(TextFieldValue(initial?.note ?: "")) }
var isSaving by remember { mutableStateOf(false) }
var authError by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var showValidation by remember { mutableStateOf(false) }
val hostFocusRequester = remember { FocusRequester() }
val portFocusRequester = remember { FocusRequester() }
val usernameFocusRequester = remember { FocusRequester() }
val serverInputValid = host.text.trim().isNotEmpty() &&
port.text.trim().isNotEmpty() &&
username.text.trim().isNotEmpty()
val hostError = showValidation && host.text.trim().isEmpty()
val portError = showValidation && port.text.trim().isEmpty()
val usernameError = showValidation && username.text.trim().isEmpty()
GlassDialog(onDismissRequest = onDismiss) {
Column(
modifier = Modifier.width(300.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(36.dp).clip(RoundedCornerShape(10.dp))
.background(Brush.linearGradient(listOf(Color(0xFF6EA8FF), Color(0xFF9B6BFF)))),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Outlined.Add, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
Spacer(Modifier.width(10.dp))
Text(text = if (initial == null) "\u6DFB\u52A0\u670D\u52A1\u5668" else "\u7F16\u8F91\u670D\u52A1\u5668", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text)
}
GlassInputField(label = "\u540D\u79F0", value = name, placeholder = "\u6211\u7684 Emby", onValueChange = { name = it })
GlassInputField(
label = "\u5730\u5740",
value = host,
placeholder = "emby.example.com",
onValueChange = { host = it },
isError = hostError,
errorMessage = if (hostError) "\u8BF7\u8F93\u5165\u5730\u5740" else null,
focusRequester = hostFocusRequester,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
GlassInputField(
label = "\u8DEF\u5F84",
value = basePath,
placeholder = "/emby",
onValueChange = { basePath = it },
modifier = Modifier.weight(1f),
)
GlassInputField(
label = "\u7AEF\u53E3",
value = port,
placeholder = "8096",
onValueChange = { port = it },
modifier = Modifier.weight(1f),
isError = portError,
errorMessage = if (portError) "\u8BF7\u8F93\u5165\u7AEF\u53E3" else null,
focusRequester = portFocusRequester,
)
}
GlassInputField(
label = "\u7528\u6237\u540D",
value = username,
placeholder = "\u7528\u6237\u540D",
onValueChange = { username = it },
isError = usernameError,
errorMessage = if (usernameError) "\u8BF7\u8F93\u5165\u7528\u6237\u540D" else null,
focusRequester = usernameFocusRequester,
)
GlassInputField(
label = "\u5BC6\u7801",
value = password,
placeholder = "\u5BC6\u7801",
onValueChange = { password = it },
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingContent = {
Icon(
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
contentDescription = if (showPassword) "\u9690\u85CF\u5BC6\u7801" else "\u663E\u793A\u5BC6\u7801",
tint = GlassTokens.subtext,
modifier = Modifier
.size(24.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) { showPassword = !showPassword },
)
},
)
GlassInputField(label = "\u5907\u6CE8", value = note, placeholder = "\u5907\u6CE8", onValueChange = { note = it })
if (authError.isNotBlank()) {
Text(text = authError, color = offlineRed, fontSize = 13.sp, modifier = Modifier.padding(bottom = 4.dp))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GlassButton(text = "\u53D6\u6D88", onClick = onDismiss, modifier = Modifier.weight(1f))
GlassButton(
text = if (isSaving) "\u4FDD\u5B58\u4E2D..." else "\u4FDD\u5B58",
onClick = {
if (isSaving) return@GlassButton
authError = ""
showValidation = true
when {
host.text.trim().isEmpty() -> runCatching { hostFocusRequester.requestFocus() }
port.text.trim().isEmpty() -> runCatching { portFocusRequester.requestFocus() }
username.text.trim().isEmpty() -> runCatching { usernameFocusRequester.requestFocus() }
else -> {
isSaving = true
val server = (initial ?: EmbyServer(id = EmbyServer.generateId())).copy(
name = name.text.trim(), host = host.text.trim(), port = port.text.trim(),
basePath = basePath.text.trim().trim('/'),
username = username.text.trim(), password = password.text.trim(), note = note.text.trim(),
isOnline = initial?.isOnline ?: true, lastActive = initial?.lastActive ?: "\u521A\u521A"
)
onConfirm(server) { error ->
isSaving = false
if (error != null) {
authError = error
} else {
onDismiss()
}
}
}
}
},
modifier = Modifier.weight(1f),
accent = true,
enabled = !isSaving,
)
}
}
}
}