From fc35ae95afa5e69be220bd4cb3d115b65cfc113a Mon Sep 17 00:00:00 2001
From: beyond
Date: Thu, 27 Aug 2026 05:45:54 +0800
Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E6=9C=8D=E5=8A=A1=E5=99=A8=E5=88=97?=
=?UTF-8?q?=E8=A1=A8=E5=A2=9E=E5=8A=A0=E7=82=B9=E5=87=BB=E5=90=8E=E8=87=AA?=
=?UTF-8?q?=E5=8A=A8=E8=B7=B3=E8=BD=AC=E9=A6=96=E9=A1=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
2、首页增加切换服务器功能
---
.gitignore | 17 ++
.../myapplication/data/AppDatabase.java | 14 +-
.../example/myapplication/data/ServerDao.java | 8 +-
.../myapplication/data/ServerEntity.java | 22 +-
.../com/example/myapplication/MainActivity.kt | 9 +-
.../com/example/myapplication/MainApp.kt | 66 ++++--
.../example/myapplication/data/EmbyServer.kt | 2 +-
.../myapplication/data/ServerRepository.kt | 25 +--
.../myapplication/screens/HomeScreen.kt | 195 +++++++++++++-----
app/src/main/res/values/themes.xml | 1 +
10 files changed, 264 insertions(+), 95 deletions(-)
diff --git a/.gitignore b/.gitignore
index a8b0d1d..7a88ddd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,20 @@ google-services.json
# Android Profiling
*.hprof
+
+# Test screenshots and debug files
+*_test.png
+screenshot*.png
+frame_*.png
+ss_*.png
+ui*.xml
+ui_dump.xml
+ui_capsule.xml
+# Common debug screenshots
+screen.png
+screen2.png
+current.png
+home_screen.png
+t1.png
+t2.png
+t3.png
diff --git a/app/src/main/java/com/example/myapplication/data/AppDatabase.java b/app/src/main/java/com/example/myapplication/data/AppDatabase.java
index 6d9d660..69be728 100644
--- a/app/src/main/java/com/example/myapplication/data/AppDatabase.java
+++ b/app/src/main/java/com/example/myapplication/data/AppDatabase.java
@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase;
@Database(
entities = {IconLibraryEntity.class, ServerEntity.class},
- version = 4,
+ version = 5,
exportSchema = false
)
public abstract class AppDatabase extends RoomDatabase {
@@ -69,7 +69,14 @@ public abstract class AppDatabase extends RoomDatabase {
}
};
- public static AppDatabase getInstance(Context context) {
+ private static final Migration MIGRATION_4_5 = new Migration(4, 5) {
+ @Override
+ public void migrate(SupportSQLiteDatabase db) {
+ db.execSQL("ALTER TABLE servers ADD COLUMN isSelected INTEGER NOT NULL DEFAULT 0");
+ }
+ };
+
+public static AppDatabase getInstance(Context context) {
if (instance == null) {
synchronized (AppDatabase.class) {
if (instance == null) {
@@ -77,11 +84,10 @@ public abstract class AppDatabase extends RoomDatabase {
context.getApplicationContext(),
AppDatabase.class,
"emby_player.db"
- ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).build();
+ ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5).build();
}
}
}
return instance;
}
}
-
diff --git a/app/src/main/java/com/example/myapplication/data/ServerDao.java b/app/src/main/java/com/example/myapplication/data/ServerDao.java
index 07d3275..167d357 100644
--- a/app/src/main/java/com/example/myapplication/data/ServerDao.java
+++ b/app/src/main/java/com/example/myapplication/data/ServerDao.java
@@ -22,4 +22,10 @@ public interface ServerDao {
void deleteLegacySampleServers();
@Query("DELETE FROM servers WHERE id = :serverId")
void deleteServer(String serverId);
-}
+
+ @Query("UPDATE servers SET isSelected = 0")
+ void clearAllSelected();
+
+ @Query("UPDATE servers SET isSelected = 1 WHERE id = :serverId")
+ void setSelected(String serverId);
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/myapplication/data/ServerEntity.java b/app/src/main/java/com/example/myapplication/data/ServerEntity.java
index bcf0c8d..41e4ec0 100644
--- a/app/src/main/java/com/example/myapplication/data/ServerEntity.java
+++ b/app/src/main/java/com/example/myapplication/data/ServerEntity.java
@@ -20,6 +20,7 @@ public class ServerEntity {
public String accessToken;
public String userId;
public String iconUrl;
+ public int isSelected;
@Ignore
public ServerEntity(
@@ -34,9 +35,10 @@ public class ServerEntity {
String accessToken,
String userId
) {
- this(id, name, host, basePath, port, username, password, note, accessToken, userId, "");
+ this(id, name, host, basePath, port, username, password, note, accessToken, userId, "", 0);
}
+ @Ignore
public ServerEntity(
String id,
String name,
@@ -49,6 +51,23 @@ public class ServerEntity {
String accessToken,
String userId,
String iconUrl
+ ) {
+ this(id, name, host, basePath, port, username, password, note, accessToken, userId, iconUrl, 0);
+ }
+
+ public ServerEntity(
+ String id,
+ String name,
+ String host,
+ String basePath,
+ String port,
+ String username,
+ String password,
+ String note,
+ String accessToken,
+ String userId,
+ String iconUrl,
+ int isSelected
) {
this.id = id;
this.name = name;
@@ -61,5 +80,6 @@ public class ServerEntity {
this.accessToken = accessToken;
this.userId = userId;
this.iconUrl = iconUrl == null ? "" : iconUrl;
+ this.isSelected = isSelected;
}
}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/example/myapplication/MainActivity.kt b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt
index 831c27e..87ab7c0 100644
--- a/app/src/main/kotlin/com/example/myapplication/MainActivity.kt
+++ b/app/src/main/kotlin/com/example/myapplication/MainActivity.kt
@@ -11,12 +11,19 @@ import com.example.myapplication.ui.AppLanguage
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
- installSplashScreen()
+ val splashScreen = installSplashScreen()
+ var dataReady = false
+ splashScreen.setKeepOnScreenCondition { !dataReady }
+
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+
+ // Initialize on main thread (these are fast SharedPreferences/DB reads)
AppLanguage.init(this)
IconLibraryRepository.init(this)
ServerRepository.init(this)
+ dataReady = true
+
setContent {
MainApp()
}
diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt
index eba750a..487a82e 100644
--- a/app/src/main/kotlin/com/example/myapplication/MainApp.kt
+++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt
@@ -1,4 +1,4 @@
-package com.example.myapplication
+package com.example.myapplication
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -13,6 +13,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.core.tween
+import androidx.compose.ui.zIndex
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -91,7 +95,7 @@ fun MainApp() {
val iconLibraries by IconLibraryRepository.libraries.collectAsState()
val coroutineScope = rememberCoroutineScope()
- val selectedServer = servers.find { it.id == selectedServerId } ?: servers.firstOrNull()
+ val selectedServer = servers.find { it.id == selectedServerId }
// Check all servers' online status when app starts or server list changes
LaunchedEffect(servers.size) {
@@ -100,6 +104,18 @@ fun MainApp() {
}
}
+ // Pick the selected server from DB, or fallback to first; handle deletion
+ LaunchedEffect(servers) {
+ if (servers.isNotEmpty()) {
+ if (selectedServerId.isEmpty()) {
+ val dbSelected = servers.firstOrNull { it.isSelected }
+ selectedServerId = dbSelected?.id ?: servers.first().id
+ } else if (servers.none { it.id == selectedServerId }) {
+ selectedServerId = servers.first().id
+ }
+ }
+ }
+
val addIconLibrary: (String, String, List, Boolean) -> Unit = { name, url, icons, isDefault ->
val library = IconLibrary(
id = "lib_${System.currentTimeMillis()}",
@@ -132,24 +148,17 @@ fun MainApp() {
}
}
- LaunchedEffect(Unit) {
- delay(2200)
- showStartScreen = false
- }
- if (showStartScreen) {
- Box(Modifier.fillMaxSize().background(Color.White)) {
- Image(
- painter = painterResource(Res.drawable.app_start),
- contentDescription = null,
- modifier = Modifier.fillMaxSize(),
- contentScale = ContentScale.Crop,
- )
+ // Dismiss splash only after both: minimum display time AND correct server selected
+ LaunchedEffect(selectedServerId) {
+ if (selectedServerId.isNotEmpty()) {
+ delay(1200)
+ showStartScreen = false
}
}
+
AppTheme.systemIsDark = isSystemInDarkTheme()
- if (!showStartScreen) {
MiuixTheme {
val hazeState = remember { HazeState() }
val glassBackdrop = rememberLayerBackdrop()
@@ -224,12 +233,18 @@ fun MainApp() {
servers = servers,
selected = selectedServer,
onDetailVisibilityChanged = { homeDetailVisible = it },
+ onSelectServer = { id ->
+ selectedServerId = id
+ selectedTab = 0
+ coroutineScope.launch { ServerRepository.setSelectedServer(id) }
+ },
+ hazeState = hazeState,
)
1 -> RecordsScreen(segment = recordsSegment, onSelect = { recordsSegment = it })
2 -> LibraryScreen(
servers = servers,
selectedServer = selectedServer,
- onSelectServer = { selectedServerId = it },
+ onSelectServer = { id -> selectedServerId = id; selectedTab = 0; coroutineScope.launch { ServerRepository.setSelectedServer(id) } },
onAddServer = { server, onResult ->
coroutineScope.launch {
try {
@@ -320,13 +335,26 @@ fun MainApp() {
modifier = Modifier.align(Alignment.BottomCenter),
)
+ // Splash overlay — always on top, fades out after delay
+ AnimatedVisibility(
+ visible = showStartScreen,
+ exit = fadeOut(animationSpec = tween(400)),
+ modifier = Modifier.fillMaxSize().zIndex(10f),
+ ) {
+ Box(Modifier.fillMaxSize().background(Color.White)) {
+ Image(
+ painter = painterResource(Res.drawable.app_start),
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop,
+ )
+ }
+ }
+
}
}
}
}
- }
}
-
-
diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt
index 37a669d..6261aa3 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt
@@ -38,6 +38,7 @@ data class EmbyServer(
val lastActive: String = "",
val iconUrl: String = "",
val authError: String = "",
+ val isSelected: Boolean = false,
) {
val fullUrl: String
get() {
@@ -72,4 +73,3 @@ data class EmbyServer(
fun generateId(): String = "server_${UUID.randomUUID()}"
}
}
-
diff --git a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt
index fc976e2..2c28ee8 100644
--- a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt
+++ b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt
@@ -1,4 +1,4 @@
-package com.example.myapplication.data
+package com.example.myapplication.data
import android.content.Context
import kotlinx.coroutines.CoroutineScope
@@ -53,7 +53,6 @@ object ServerRepository {
}
fun setRuntimeIcon(serverId: String, iconUrl: String) {
- // Update memory immediately for responsive UI...
val updated = mutableListOf()
var changed: EmbyServer? = null
for (server in _servers.value) {
@@ -65,7 +64,6 @@ object ServerRepository {
}
}
_servers.value = updated
- // ...and persist to Room so the icon survives app restarts
changed?.let { srv ->
repositoryScope.launch {
database?.serverDao()?.insertServer(toEntity(srv))
@@ -82,16 +80,13 @@ object ServerRepository {
suspend fun checkAllServersOnline() {
val currentServers = _servers.value.toList()
for (server in currentServers) {
- // First verify server is reachable
val reachable = EmbyRepository.checkOnline(server)
if (!reachable) {
updateServerStatus(server.id, false, "无法连接到服务器")
continue
}
- // Then try to authenticate to verify credentials
val authResult = EmbyRepository.authenticate(server)
if (authResult.success) {
- // Update token if it's different from stored one
if (server.apiKey != authResult.accessToken) {
val updated = server.copy(
apiKey = authResult.accessToken,
@@ -112,6 +107,14 @@ object ServerRepository {
}
}
+ suspend fun setSelectedServer(serverId: String) {
+ withContext(Dispatchers.IO) {
+ database?.serverDao()?.clearAllSelected()
+ database?.serverDao()?.setSelected(serverId)
+ }
+ reload()
+ }
+
private suspend fun reload(): List {
val previousServers = _servers.value.toList()
val entities = withContext(Dispatchers.IO) {
@@ -119,7 +122,6 @@ object ServerRepository {
}
val result = mutableListOf()
for (entity in entities) {
- // Preserve runtime-only fields (isOnline, lastActive) from previous state
val prev = previousServers.find { it.id == entity.id }
result.add(
toModel(entity).copy(
@@ -161,12 +163,12 @@ object ServerRepository {
}
}
-
private suspend fun removeLegacySampleServers() {
withContext(Dispatchers.IO) {
database?.serverDao()?.deleteLegacySampleServers()
}
}
+
private fun toEntity(server: EmbyServer): ServerEntity {
return ServerEntity(
server.id,
@@ -180,6 +182,7 @@ object ServerRepository {
server.apiKey,
server.userId,
server.iconUrl,
+ if (server.isSelected) 1 else 0,
)
}
@@ -196,9 +199,7 @@ object ServerRepository {
apiKey = entity.accessToken ?: "",
userId = entity.userId ?: "",
iconUrl = entity.iconUrl ?: "",
+ isSelected = entity.isSelected == 1,
)
}
-}
-
-
-
+}
\ No newline at end of file
diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
index 26fccac..7684e04 100644
--- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
+++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt
@@ -92,6 +92,25 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import top.yukonga.miuix.kmp.basic.Text
import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.material.icons.filled.ArrowDropDown
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.text.TextStyle
+import com.example.myapplication.ui.AppTheme
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.hazeEffect
+import dev.chrisbanes.haze.HazeStyle
+import dev.chrisbanes.haze.HazeTint
+import androidx.compose.runtime.mutableIntStateOf
/* ---- Adaptive content colors for the immersive home background ---- */
val LocalHomeContentColor = staticCompositionLocalOf { Color.Unspecified }
@@ -592,6 +611,8 @@ fun HomeScreen(
servers: List = emptyList(),
selected: EmbyServer? = null,
onDetailVisibilityChanged: (Boolean) -> Unit = {},
+ onSelectServer: (String) -> Unit = {},
+ hazeState: HazeState? = null,
) {
val activeServer = remember(servers, selected) { selected ?: servers.firstOrNull { it.apiKey.isNotBlank() } }
@@ -610,11 +631,13 @@ fun HomeScreen(
val activeHeroItem = heroItems.getOrNull(heroPagerState.currentPage)
val immersiveBackground = rememberPosterImmersiveBackground(heroImageUrl(activeHeroItem, activeServer?.fullUrl.orEmpty()))
- LaunchedEffect(activeServer?.id, activeServer?.apiKey) {
+ var lastLoadedServerId by remember { mutableStateOf("") }
+
+ LaunchedEffect(activeServer?.id) {
if (activeServer == null || activeServer.apiKey.isBlank()) return@LaunchedEffect
+ if (activeServer.id == lastLoadedServerId) return@LaunchedEffect
loading = true
try {
- // Fetch everything concurrently; commit state only when all data is ready
val libsDeferred = async { EmbyRepository.getLibraries(activeServer) }
val latestDeferred = async { EmbyRepository.getLatestItems(activeServer, limit = 10) }
val resumeDeferred = async { EmbyRepository.getResumeItems(activeServer, limit = 10) }
@@ -628,15 +651,14 @@ fun HomeScreen(
HomeUiState.selectedLibrary = libs.firstOrNull()
}
- // First screen renders immediately; per-library sections load on demand when scrolled into view
libraries = libs
latestItems = latest
resumeItems = resume
+ lastLoadedServerId = activeServer.id
} finally {
loading = false
}
}
-
val surfaceIsDark = immersiveBackground.isDark
val adaptiveText = if (surfaceIsDark) Color(0xFFF4F7FB) else Color(0xFF121417)
val adaptiveSecondary = if (surfaceIsDark) Color.White.copy(alpha = 0.74f) else Color.Black.copy(alpha = 0.64f)
@@ -728,13 +750,15 @@ fun HomeScreen(
}
}
- // Overlay top bar: selected library name + matching icon, floats over poster
HomeOverlayTopBar(
name = activeServer?.name ?: "",
iconUrl = activeServer?.iconUrl ?: "",
+ servers = servers,
+ activeServerId = activeServer?.id ?: "",
+ onSelectServer = onSelectServer,
+ hazeState = hazeState,
modifier = Modifier.align(Alignment.TopCenter),
)
-
detailItem?.let { item ->
activeServer?.let { server ->
MediaDetailScreen(
@@ -748,71 +772,130 @@ fun HomeScreen(
}
}
-/* ---- Overlay top bar showing selected server (live name + custom icon) ---- */
+/* ---- Overlay top bar: liquid-glass adaptive capsule with server dropdown ---- */
@Composable
private fun HomeOverlayTopBar(
name: String,
iconUrl: String,
+ servers: List,
+ activeServerId: String,
+ onSelectServer: (String) -> Unit,
+ hazeState: HazeState? = null,
modifier: Modifier = Modifier,
) {
val title = name.ifBlank { "个人媒体中心" }
- Row(
+ val dark = AppTheme.isDark
+ val capsuleShape = RoundedCornerShape(22.dp)
+ var showDropdown by remember { mutableStateOf(false) }
+ BackHandler(enabled = showDropdown) { showDropdown = false }
+
+ Box(
modifier.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 10.dp),
- verticalAlignment = Alignment.CenterVertically,
) {
- Row(
- Modifier.widthIn(min = 168.dp)
- .clip(RoundedCornerShape(22.dp))
- .background(
- Brush.verticalGradient(
- listOf(
- Color.White.copy(alpha = 0.42f),
- Color.White.copy(alpha = 0.28f),
- )
+ Column {
+ /* ---- Capsule button ---- */
+ Box(
+ Modifier
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) { showDropdown = !showDropdown }
+ .shadow(elevation = 14.dp, shape = capsuleShape, ambientColor = GlassTokens.glassShadow, spotColor = GlassTokens.glassShadow)
+ .clip(capsuleShape)
+ .background(GlassTokens.glassFill)
+ .border(1.dp, Color.White.copy(alpha = if (dark) 0.45f else 0.75f), capsuleShape)
+ ) {
+ /* Specular highlight */
+ Box(Modifier.matchParentSize().background(Brush.verticalGradient(0f to Color.White.copy(alpha = if (dark) 0.18f else 0.70f), 0.06f to Color.White.copy(alpha = if (dark) 0.08f else 0.38f), 0.18f to Color.Transparent, 0.82f to Color.Transparent, 1f to Color.White.copy(alpha = if (dark) 0.06f else 0.18f))))
+
+ val animatedRotation by animateFloatAsState(targetValue = if (showDropdown) 180f else 0f, animationSpec = tween(durationMillis = 300), label = "chevron")
+
+ Row(Modifier.padding(horizontal = 14.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically) {
+ /* Liquid-glass icon circle */
+ Box(
+ modifier = Modifier.size(28.dp)
+ .shadow(elevation = 6.dp, shape = CircleShape, ambientColor = GlassTokens.glassShadow, spotColor = GlassTokens.glassShadow)
+ .clip(CircleShape).background(GlassTokens.glassFillStrong)
+ .border(1.dp, Color.White.copy(alpha = if (dark) 0.50f else 0.80f), CircleShape),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(Modifier.matchParentSize().background(Brush.verticalGradient(0f to Color.White.copy(alpha = if (dark) 0.22f else 0.72f), 0.15f to Color.Transparent, 0.85f to Color.Transparent, 1f to Color.White.copy(alpha = if (dark) 0.08f else 0.22f))))
+ if (iconUrl.isNotBlank()) {
+ AsyncImage(model = ImageRequest.Builder(LocalContext.current).data(iconUrl).crossfade(true).build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(20.dp).clip(CircleShape))
+ } else {
+ Icon(imageVector = Icons.Outlined.Storage, contentDescription = null, tint = GlassTokens.text, modifier = Modifier.size(14.dp))
+ }
+ }
+
+ Spacer(Modifier.width(8.dp))
+
+ /* Server name - rainbow gradient */
+ GradientText(
+ text = title,
+ colors = if (dark) listOf(Color(0xFFFF6B6B), Color(0xFFFF8E53), Color(0xFFFFC947), Color(0xFF4ECDC4), Color(0xFF45B7D1), Color(0xFF96E6A1))
+ else listOf(Color(0xFF1A1A2E), Color(0xFF16213E), Color(0xFF0F3460), Color(0xFF533483), Color(0xFFE94560), Color(0xFF0F3460)),
+ fontSize = 15.sp, fontWeight = FontWeight.Bold,
)
- )
- .border(0.8.dp, Color.White.copy(alpha = 0.60f), RoundedCornerShape(22.dp))
- .padding(horizontal = 16.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- if (iconUrl.isNotBlank()) {
- AsyncImage(
- model = ImageRequest.Builder(LocalContext.current).data(iconUrl).crossfade(true).build(),
- contentDescription = null,
- contentScale = ContentScale.Crop,
- modifier = Modifier.size(22.dp).clip(RoundedCornerShape(6.dp)),
- )
- } else {
- Icon(imageVector = Icons.Outlined.Storage, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp))
+
+ Spacer(Modifier.width(4.dp))
+
+ Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(18.dp).rotate(animatedRotation))
+ }
+ }
+
+ /* ---- Server dropdown list ---- */
+ AnimatedVisibility(
+ visible = showDropdown,
+ enter = expandVertically(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)) + fadeIn(animationSpec = tween(200)),
+ exit = shrinkVertically(animationSpec = tween(150)) + fadeOut(animationSpec = tween(100)),
+ ) {
+ Spacer(Modifier.height(12.dp))
+ Column(
+ Modifier.fillMaxWidth(0.5f).widthIn(min = 240.dp, max = 300.dp)
+ .clip(RoundedCornerShape(22.dp))
+ .then(if (hazeState != null) Modifier.hazeEffect(state = hazeState, style = HazeStyle(blurRadius = 60.dp, noiseFactor = 0.03f, tint = HazeTint(Color.Black.copy(alpha = if (dark) 0.88f else 0.85f)))) else Modifier)
+ .background(Brush.verticalGradient(listOf(Color.White.copy(alpha = if (dark) 0.10f else 0.50f), Color.White.copy(alpha = if (dark) 0.06f else 0.35f), Color(0xFFCBD0DA).copy(alpha = if (dark) 0.10f else 0.38f))))
+ .border(1.dp, Color.White.copy(alpha = if (dark) 0.28f else 0.50f), RoundedCornerShape(22.dp))
+ .padding(vertical = 6.dp)
+ ) {
+ servers.forEach { server ->
+ ServerDropdownItem(server = server, isActive = server.id == activeServerId, dark = dark, onClick = { onSelectServer(server.id); showDropdown = false })
+ }
+ }
}
- Spacer(Modifier.width(7.dp))
- Text(
- text = title,
- color = Color.White,
- fontSize = 15.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- style = LocalTextStyle.current.copy(
- shadow = Shadow(color = Color.Black.copy(alpha = 0.45f), offset = Offset(0f, 1f), blurRadius = 6f),
- ),
- )
}
-
-
}
}
+/* ---- Server dropdown item (capsule-shaped) ---- */
+@Composable
+private fun ServerDropdownItem(server: EmbyServer, isActive: Boolean, dark: Boolean, onClick: () -> Unit) {
+ Row(
+ Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .then(if (isActive) Modifier.background(Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = if (dark) 0.20f else 0.14f), GlassTokens.accent.copy(alpha = if (dark) 0.10f else 0.06f)))) else Modifier.background(Brush.verticalGradient(listOf(Color.White.copy(alpha = if (dark) 0.08f else 0.30f), Color.White.copy(alpha = if (dark) 0.04f else 0.14f)))))
+ .border(0.6.dp, if (isActive) Color.White.copy(alpha = if (dark) 0.20f else 0.40f) else Color.White.copy(alpha = if (dark) 0.10f else 0.20f), RoundedCornerShape(16.dp))
+ .clickable(onClick = onClick)
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(modifier = Modifier.size(24.dp).clip(CircleShape).background(GlassTokens.glassFillStrong).border(0.6.dp, Color.White.copy(alpha = if (dark) 0.35f else 0.55f), CircleShape), contentAlignment = Alignment.Center) {
+ if (server.iconUrl.isNotBlank()) {
+ AsyncImage(model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(18.dp).clip(CircleShape))
+ } else {
+ Icon(imageVector = Icons.Outlined.Storage, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(13.dp))
+ }
+ }
+ Spacer(Modifier.width(10.dp))
+ Text(text = server.name, color = if (isActive) GlassTokens.accent else GlassTokens.text, fontSize = 14.sp, fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
+ Box(modifier = Modifier.size(6.dp).clip(CircleShape).background(if (server.isOnline) Color(0xFF34C759) else Color(0xFFFF3B30)))
+ }
+}
-
-
-
-
-
-
-
-
-
-
+/* ---- Rainbow gradient text ---- */
+@Composable
+private fun GradientText(text: String, colors: List, fontSize: androidx.compose.ui.unit.TextUnit = 15.sp, fontWeight: FontWeight = FontWeight.Bold) {
+ Text(text = text, fontSize = fontSize, fontWeight = fontWeight, maxLines = 1, overflow = TextOverflow.Ellipsis, style = TextStyle(brush = Brush.linearGradient(colors)))
+}
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index a1bc890..74a9148 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -8,6 +8,7 @@