1、服务器列表增加点击后自动跳转首页
2、首页增加切换服务器功能
This commit is contained in:
1 parent
6ee79f14cb
commit
fc35ae95af
10 files changed
+264
-95
No files matched your search
+17
@@ -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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user