1、服务器列表增加点击后自动跳转首页

2、首页增加切换服务器功能
This commit is contained in:
beyond committed 2026-08-27 05:45:54 +08:00
1 parent 6ee79f14cb
commit fc35ae95af
10 files changed
+264 -95

No files matched your search

+17
View File
@@ -33,3 +33,20 @@ google-services.json
# Android Profiling # Android Profiling
*.hprof *.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
@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase;
@Database( @Database(
entities = {IconLibraryEntity.class, ServerEntity.class}, entities = {IconLibraryEntity.class, ServerEntity.class},
version = 4, version = 5,
exportSchema = false exportSchema = false
) )
public abstract class AppDatabase extends RoomDatabase { 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) { if (instance == null) {
synchronized (AppDatabase.class) { synchronized (AppDatabase.class) {
if (instance == null) { if (instance == null) {
@@ -77,11 +84,10 @@ public abstract class AppDatabase extends RoomDatabase {
context.getApplicationContext(), context.getApplicationContext(),
AppDatabase.class, AppDatabase.class,
"emby_player.db" "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; return instance;
} }
} }
@@ -22,4 +22,10 @@ public interface ServerDao {
void deleteLegacySampleServers(); void deleteLegacySampleServers();
@Query("DELETE FROM servers WHERE id = :serverId") @Query("DELETE FROM servers WHERE id = :serverId")
void deleteServer(String 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);
}
@@ -20,6 +20,7 @@ public class ServerEntity {
public String accessToken; public String accessToken;
public String userId; public String userId;
public String iconUrl; public String iconUrl;
public int isSelected;
@Ignore @Ignore
public ServerEntity( public ServerEntity(
@@ -34,9 +35,10 @@ public class ServerEntity {
String accessToken, String accessToken,
String userId 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( public ServerEntity(
String id, String id,
String name, String name,
@@ -49,6 +51,23 @@ public class ServerEntity {
String accessToken, String accessToken,
String userId, String userId,
String iconUrl 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.id = id;
this.name = name; this.name = name;
@@ -61,5 +80,6 @@ public class ServerEntity {
this.accessToken = accessToken; this.accessToken = accessToken;
this.userId = userId; this.userId = userId;
this.iconUrl = iconUrl == null ? "" : iconUrl; this.iconUrl = iconUrl == null ? "" : iconUrl;
this.isSelected = isSelected;
} }
} }
@@ -11,12 +11,19 @@ import com.example.myapplication.ui.AppLanguage
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen() val splashScreen = installSplashScreen()
var dataReady = false
splashScreen.setKeepOnScreenCondition { !dataReady }
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// Initialize on main thread (these are fast SharedPreferences/DB reads)
AppLanguage.init(this) AppLanguage.init(this)
IconLibraryRepository.init(this) IconLibraryRepository.init(this)
ServerRepository.init(this) ServerRepository.init(this)
dataReady = true
setContent { setContent {
MainApp() MainApp()
} }
@@ -1,4 +1,4 @@
package com.example.myapplication package com.example.myapplication
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -13,6 +13,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -91,7 +95,7 @@ fun MainApp() {
val iconLibraries by IconLibraryRepository.libraries.collectAsState() val iconLibraries by IconLibraryRepository.libraries.collectAsState()
val coroutineScope = rememberCoroutineScope() 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 // Check all servers' online status when app starts or server list changes
LaunchedEffect(servers.size) { 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<IconItem>, Boolean) -> Unit = { name, url, icons, isDefault -> val addIconLibrary: (String, String, List<IconItem>, Boolean) -> Unit = { name, url, icons, isDefault ->
val library = IconLibrary( val library = IconLibrary(
id = "lib_${System.currentTimeMillis()}", id = "lib_${System.currentTimeMillis()}",
@@ -132,24 +148,17 @@ fun MainApp() {
} }
} }
LaunchedEffect(Unit) {
delay(2200)
showStartScreen = false
}
if (showStartScreen) { // Dismiss splash only after both: minimum display time AND correct server selected
Box(Modifier.fillMaxSize().background(Color.White)) { LaunchedEffect(selectedServerId) {
Image( if (selectedServerId.isNotEmpty()) {
painter = painterResource(Res.drawable.app_start), delay(1200)
contentDescription = null, showStartScreen = false
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} }
} }
AppTheme.systemIsDark = isSystemInDarkTheme() AppTheme.systemIsDark = isSystemInDarkTheme()
if (!showStartScreen) {
MiuixTheme { MiuixTheme {
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val glassBackdrop = rememberLayerBackdrop() val glassBackdrop = rememberLayerBackdrop()
@@ -224,12 +233,18 @@ fun MainApp() {
servers = servers, servers = servers,
selected = selectedServer, selected = selectedServer,
onDetailVisibilityChanged = { homeDetailVisible = it }, onDetailVisibilityChanged = { homeDetailVisible = it },
onSelectServer = { id ->
selectedServerId = id
selectedTab = 0
coroutineScope.launch { ServerRepository.setSelectedServer(id) }
},
hazeState = hazeState,
) )
1 -> RecordsScreen(segment = recordsSegment, onSelect = { recordsSegment = it }) 1 -> RecordsScreen(segment = recordsSegment, onSelect = { recordsSegment = it })
2 -> LibraryScreen( 2 -> LibraryScreen(
servers = servers, servers = servers,
selectedServer = selectedServer, selectedServer = selectedServer,
onSelectServer = { selectedServerId = it }, onSelectServer = { id -> selectedServerId = id; selectedTab = 0; coroutineScope.launch { ServerRepository.setSelectedServer(id) } },
onAddServer = { server, onResult -> onAddServer = { server, onResult ->
coroutineScope.launch { coroutineScope.launch {
try { try {
@@ -320,13 +335,26 @@ fun MainApp() {
modifier = Modifier.align(Alignment.BottomCenter), 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,
)
}
}
} }
} }
} }
} }
}
} }
@@ -38,6 +38,7 @@ data class EmbyServer(
val lastActive: String = "", val lastActive: String = "",
val iconUrl: String = "", val iconUrl: String = "",
val authError: String = "", val authError: String = "",
val isSelected: Boolean = false,
) { ) {
val fullUrl: String val fullUrl: String
get() { get() {
@@ -72,4 +73,3 @@ data class EmbyServer(
fun generateId(): String = "server_${UUID.randomUUID()}" fun generateId(): String = "server_${UUID.randomUUID()}"
} }
} }
@@ -1,4 +1,4 @@
package com.example.myapplication.data package com.example.myapplication.data
import android.content.Context import android.content.Context
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -53,7 +53,6 @@ object ServerRepository {
} }
fun setRuntimeIcon(serverId: String, iconUrl: String) { fun setRuntimeIcon(serverId: String, iconUrl: String) {
// Update memory immediately for responsive UI...
val updated = mutableListOf<EmbyServer>() val updated = mutableListOf<EmbyServer>()
var changed: EmbyServer? = null var changed: EmbyServer? = null
for (server in _servers.value) { for (server in _servers.value) {
@@ -65,7 +64,6 @@ object ServerRepository {
} }
} }
_servers.value = updated _servers.value = updated
// ...and persist to Room so the icon survives app restarts
changed?.let { srv -> changed?.let { srv ->
repositoryScope.launch { repositoryScope.launch {
database?.serverDao()?.insertServer(toEntity(srv)) database?.serverDao()?.insertServer(toEntity(srv))
@@ -82,16 +80,13 @@ object ServerRepository {
suspend fun checkAllServersOnline() { suspend fun checkAllServersOnline() {
val currentServers = _servers.value.toList() val currentServers = _servers.value.toList()
for (server in currentServers) { for (server in currentServers) {
// First verify server is reachable
val reachable = EmbyRepository.checkOnline(server) val reachable = EmbyRepository.checkOnline(server)
if (!reachable) { if (!reachable) {
updateServerStatus(server.id, false, "无法连接到服务器") updateServerStatus(server.id, false, "无法连接到服务器")
continue continue
} }
// Then try to authenticate to verify credentials
val authResult = EmbyRepository.authenticate(server) val authResult = EmbyRepository.authenticate(server)
if (authResult.success) { if (authResult.success) {
// Update token if it's different from stored one
if (server.apiKey != authResult.accessToken) { if (server.apiKey != authResult.accessToken) {
val updated = server.copy( val updated = server.copy(
apiKey = authResult.accessToken, 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<EmbyServer> { private suspend fun reload(): List<EmbyServer> {
val previousServers = _servers.value.toList() val previousServers = _servers.value.toList()
val entities = withContext(Dispatchers.IO) { val entities = withContext(Dispatchers.IO) {
@@ -119,7 +122,6 @@ object ServerRepository {
} }
val result = mutableListOf<EmbyServer>() val result = mutableListOf<EmbyServer>()
for (entity in entities) { for (entity in entities) {
// Preserve runtime-only fields (isOnline, lastActive) from previous state
val prev = previousServers.find { it.id == entity.id } val prev = previousServers.find { it.id == entity.id }
result.add( result.add(
toModel(entity).copy( toModel(entity).copy(
@@ -161,12 +163,12 @@ object ServerRepository {
} }
} }
private suspend fun removeLegacySampleServers() { private suspend fun removeLegacySampleServers() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
database?.serverDao()?.deleteLegacySampleServers() database?.serverDao()?.deleteLegacySampleServers()
} }
} }
private fun toEntity(server: EmbyServer): ServerEntity { private fun toEntity(server: EmbyServer): ServerEntity {
return ServerEntity( return ServerEntity(
server.id, server.id,
@@ -180,6 +182,7 @@ object ServerRepository {
server.apiKey, server.apiKey,
server.userId, server.userId,
server.iconUrl, server.iconUrl,
if (server.isSelected) 1 else 0,
) )
} }
@@ -196,9 +199,7 @@ object ServerRepository {
apiKey = entity.accessToken ?: "", apiKey = entity.accessToken ?: "",
userId = entity.userId ?: "", userId = entity.userId ?: "",
iconUrl = entity.iconUrl ?: "", iconUrl = entity.iconUrl ?: "",
isSelected = entity.isSelected == 1,
) )
} }
} }
@@ -92,6 +92,25 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.Text
import androidx.activity.compose.BackHandler 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 ---- */ /* ---- Adaptive content colors for the immersive home background ---- */
val LocalHomeContentColor = staticCompositionLocalOf { Color.Unspecified } val LocalHomeContentColor = staticCompositionLocalOf { Color.Unspecified }
@@ -592,6 +611,8 @@ fun HomeScreen(
servers: List<EmbyServer> = emptyList(), servers: List<EmbyServer> = emptyList(),
selected: EmbyServer? = null, selected: EmbyServer? = null,
onDetailVisibilityChanged: (Boolean) -> Unit = {}, onDetailVisibilityChanged: (Boolean) -> Unit = {},
onSelectServer: (String) -> Unit = {},
hazeState: HazeState? = null,
) { ) {
val activeServer = remember(servers, selected) { selected ?: servers.firstOrNull { it.apiKey.isNotBlank() } } val activeServer = remember(servers, selected) { selected ?: servers.firstOrNull { it.apiKey.isNotBlank() } }
@@ -610,11 +631,13 @@ fun HomeScreen(
val activeHeroItem = heroItems.getOrNull(heroPagerState.currentPage) val activeHeroItem = heroItems.getOrNull(heroPagerState.currentPage)
val immersiveBackground = rememberPosterImmersiveBackground(heroImageUrl(activeHeroItem, activeServer?.fullUrl.orEmpty())) 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 == null || activeServer.apiKey.isBlank()) return@LaunchedEffect
if (activeServer.id == lastLoadedServerId) return@LaunchedEffect
loading = true loading = true
try { try {
// Fetch everything concurrently; commit state only when all data is ready
val libsDeferred = async { EmbyRepository.getLibraries(activeServer) } val libsDeferred = async { EmbyRepository.getLibraries(activeServer) }
val latestDeferred = async { EmbyRepository.getLatestItems(activeServer, limit = 10) } val latestDeferred = async { EmbyRepository.getLatestItems(activeServer, limit = 10) }
val resumeDeferred = async { EmbyRepository.getResumeItems(activeServer, limit = 10) } val resumeDeferred = async { EmbyRepository.getResumeItems(activeServer, limit = 10) }
@@ -628,15 +651,14 @@ fun HomeScreen(
HomeUiState.selectedLibrary = libs.firstOrNull() HomeUiState.selectedLibrary = libs.firstOrNull()
} }
// First screen renders immediately; per-library sections load on demand when scrolled into view
libraries = libs libraries = libs
latestItems = latest latestItems = latest
resumeItems = resume resumeItems = resume
lastLoadedServerId = activeServer.id
} finally { } finally {
loading = false loading = false
} }
} }
val surfaceIsDark = immersiveBackground.isDark val surfaceIsDark = immersiveBackground.isDark
val adaptiveText = if (surfaceIsDark) Color(0xFFF4F7FB) else Color(0xFF121417) 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) 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( HomeOverlayTopBar(
name = activeServer?.name ?: "", name = activeServer?.name ?: "",
iconUrl = activeServer?.iconUrl ?: "", iconUrl = activeServer?.iconUrl ?: "",
servers = servers,
activeServerId = activeServer?.id ?: "",
onSelectServer = onSelectServer,
hazeState = hazeState,
modifier = Modifier.align(Alignment.TopCenter), modifier = Modifier.align(Alignment.TopCenter),
) )
detailItem?.let { item -> detailItem?.let { item ->
activeServer?.let { server -> activeServer?.let { server ->
MediaDetailScreen( 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 @Composable
private fun HomeOverlayTopBar( private fun HomeOverlayTopBar(
name: String, name: String,
iconUrl: String, iconUrl: String,
servers: List<EmbyServer>,
activeServerId: String,
onSelectServer: (String) -> Unit,
hazeState: HazeState? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = name.ifBlank { "个人媒体中心" } 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() modifier.fillMaxWidth()
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 10.dp), .padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Row( Column {
Modifier.widthIn(min = 168.dp) /* ---- Capsule button ---- */
.clip(RoundedCornerShape(22.dp)) Box(
.background( Modifier
Brush.verticalGradient( .clickable(
listOf( interactionSource = remember { MutableInteractionSource() },
Color.White.copy(alpha = 0.42f), indication = null,
Color.White.copy(alpha = 0.28f), ) { 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)) Spacer(Modifier.width(4.dp))
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(18.dp).rotate(animatedRotation))
) { }
if (iconUrl.isNotBlank()) { }
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(iconUrl).crossfade(true).build(), /* ---- Server dropdown list ---- */
contentDescription = null, AnimatedVisibility(
contentScale = ContentScale.Crop, visible = showDropdown,
modifier = Modifier.size(22.dp).clip(RoundedCornerShape(6.dp)), enter = expandVertically(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)) + fadeIn(animationSpec = tween(200)),
) exit = shrinkVertically(animationSpec = tween(150)) + fadeOut(animationSpec = tween(100)),
} else { ) {
Icon(imageVector = Icons.Outlined.Storage, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp)) 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<Color>, 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)))
}
+1
View File
@@ -8,6 +8,7 @@
<style name="Theme.MyApplication.Starting" parent="Theme.SplashScreen"> <style name="Theme.MyApplication.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/window_background</item> <item name="windowSplashScreenBackground">@color/window_background</item>
<item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item> <item name="windowSplashScreenAnimatedIcon">@mipmap/ic_launcher</item>
<item name="android:windowBackground">@color/window_background</item>
<item name="postSplashScreenTheme">@style/Theme.MyApplication</item> <item name="postSplashScreenTheme">@style/Theme.MyApplication</item>
</style> </style>
</resources> </resources>