From fa645b0a67295bb12d8c89570a2e72c5745c4c38 Mon Sep 17 00:00:00 2001 From: BoatingCloud <1759888028@qq.com> Date: Thu, 27 Aug 2026 17:39:37 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=A1=B5=E9=9D=A2=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=BA=95=E6=A0=8F=E8=AF=AF?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 +- .../myapplication/data/AppDatabase.java | 13 +- .../myapplication/data/ServerEntity.java | 27 +- .../com/example/myapplication/MainApp.kt | 379 +++++--------- .../example/myapplication/data/EmbyServer.kt | 12 +- .../myapplication/data/ServerRepository.kt | 4 +- .../myapplication/navigation/AppNavHost.kt | 363 +++++++++++++ .../myapplication/navigation/AppRoute.kt | 41 ++ .../navigation/NavigationTransitions.kt | 75 +++ .../myapplication/screens/HomeScreen.kt | 37 +- .../myapplication/screens/LibraryScreen.kt | 493 ++++++++++++++---- .../screens/MediaDetailScreen.kt | 81 +-- .../myapplication/screens/RecordsScreen.kt | 18 +- .../myapplication/screens/SearchScreen.kt | 17 +- .../screens/SettingsChildScreens.kt | 168 ++++++ .../myapplication/screens/SettingsScreen.kt | 4 - .../screens/VideoPlayerScreen.kt | 48 +- .../library/IconLibraryManagerScreen.kt | 27 + .../example/myapplication/ui/LiquidGlass.kt | 209 +++++++- memory/2026-08-27.md | 42 ++ memory/MEMORY.md | 4 +- memory/lessons.md | 2 + memory/projects.md | 7 + 23 files changed, 1541 insertions(+), 533 deletions(-) create mode 100644 app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt create mode 100644 app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt create mode 100644 app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt create mode 100644 app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt create mode 100644 app/src/main/kotlin/com/example/myapplication/screens/library/IconLibraryManagerScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3687fce..935899e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("com.android.application") version "9.0.0" + id("com.android.application") version "9.0.1" id("org.jetbrains.kotlin.plugin.compose") } @@ -45,6 +45,7 @@ dependencies { implementation("io.coil-kt:coil-compose:2.7.0") implementation("androidx.palette:palette-ktx:1.0.0") implementation("androidx.activity:activity-compose:1.13.0") + implementation("androidx.navigation:navigation-compose:2.10.0") implementation("androidx.core:core-splashscreen:1.0.1") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.1") implementation("top.yukonga.miuix.kmp:miuix-ui:0.9.4-rc01") 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 69be728..08bae06 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 = 5, + version = 6, exportSchema = false ) public abstract class AppDatabase extends RoomDatabase { @@ -69,13 +69,20 @@ public abstract class AppDatabase extends RoomDatabase { } }; - private static final Migration MIGRATION_4_5 = new Migration(4, 5) { + 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"); } }; + private static final Migration MIGRATION_5_6 = new Migration(5, 6) { + @Override + public void migrate(SupportSQLiteDatabase db) { + db.execSQL("ALTER TABLE servers ADD COLUMN protocol TEXT"); + } + }; + public static AppDatabase getInstance(Context context) { if (instance == null) { synchronized (AppDatabase.class) { @@ -84,7 +91,7 @@ public static AppDatabase getInstance(Context context) { context.getApplicationContext(), AppDatabase.class, "emby_player.db" - ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5).build(); + ).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6).build(); } } } 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 41e4ec0..b4432e7 100644 --- a/app/src/main/java/com/example/myapplication/data/ServerEntity.java +++ b/app/src/main/java/com/example/myapplication/data/ServerEntity.java @@ -11,6 +11,7 @@ public class ServerEntity { @NonNull public String id; public String name; + public String protocol; public String host; public String basePath; public String port; @@ -35,7 +36,7 @@ public class ServerEntity { String accessToken, String userId ) { - this(id, name, host, basePath, port, username, password, note, accessToken, userId, "", 0); + this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, "", 0); } @Ignore @@ -52,9 +53,10 @@ public class ServerEntity { String userId, String iconUrl ) { - this(id, name, host, basePath, port, username, password, note, accessToken, userId, iconUrl, 0); + this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, iconUrl, 0); } + @Ignore public ServerEntity( String id, String name, @@ -68,9 +70,28 @@ public class ServerEntity { String userId, String iconUrl, int isSelected + ) { + this(id, name, "https", host, basePath, port, username, password, note, accessToken, userId, iconUrl, isSelected); + } + + public ServerEntity( + String id, + String name, + String protocol, + 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; + this.protocol = protocol == null ? "https" : protocol; this.host = host; this.basePath = basePath; this.port = port; @@ -82,4 +103,4 @@ public class ServerEntity { this.iconUrl = iconUrl == null ? "" : iconUrl; this.isSelected = isSelected; } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt index 73a3f87..aec1f26 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainApp.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt @@ -1,32 +1,18 @@ -package com.example.myapplication +package com.example.myapplication -import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith -import androidx.compose.foundation.Image +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.isSystemInDarkTheme import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -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.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -35,280 +21,203 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable 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.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.example.myapplication.R as Res +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController import com.example.myapplication.data.EmbyRepository import com.example.myapplication.data.EmbyServer import com.example.myapplication.data.IconLibrary import com.example.myapplication.data.IconLibraryRepository import com.example.myapplication.data.ServerRepository -import com.example.myapplication.screens.HomeScreen -import com.example.myapplication.screens.IconLibraryManagerScreen -import com.example.myapplication.screens.LibraryScreen -import com.example.myapplication.screens.LibraryTopBar -import com.example.myapplication.screens.RecordsScreen -import com.example.myapplication.screens.RecordsTopBar -import com.example.myapplication.screens.SearchScreen -import com.example.myapplication.screens.SearchTopBar -import com.example.myapplication.screens.SettingsScreen -import com.example.myapplication.screens.SettingsTopBar +import com.example.myapplication.navigation.AppNavHost +import com.example.myapplication.navigation.AppRoute +import com.example.myapplication.navigation.isRootRoute +import com.example.myapplication.navigation.rootTabIndex import com.example.myapplication.ui.AppTheme -import com.example.myapplication.ui.GlassTokens import com.example.myapplication.ui.LiquidAppBottomBar import com.example.myapplication.ui.LocalGlassBackdrop import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.theme.MiuixTheme @OptIn(ExperimentalAnimationApi::class) @Composable fun MainApp() { - var selectedTab by remember { mutableIntStateOf(0) } + val navController = rememberNavController() var selectedServerId by remember { mutableStateOf("") } - var recordsSegment by remember { mutableIntStateOf(0) } - var searchQuery by remember { mutableStateOf("") } - var showLibraryAddDialog by remember { mutableStateOf(false) } - var libraryIsGrid by remember { mutableStateOf(false) } - var showIconLibraryManager by remember { mutableStateOf(false) } - var showCacheDetail by remember { mutableStateOf(false) } - var detailVisible by remember { mutableStateOf(false) } - var showAboutDetail by remember { mutableStateOf(false) } var changeIconTarget by remember { mutableStateOf(null) } var showStartScreen by remember { mutableStateOf(true) } val servers by ServerRepository.servers.collectAsState() val iconLibraries by IconLibraryRepository.libraries.collectAsState() val coroutineScope = rememberCoroutineScope() - val selectedServer = servers.find { it.id == selectedServerId } LaunchedEffect(servers.size) { - if (servers.isNotEmpty()) { - ServerRepository.checkAllServersOnline() - } + if (servers.isNotEmpty()) ServerRepository.checkAllServersOnline() } - LaunchedEffect(servers) { if (servers.isNotEmpty()) { - if (selectedServerId.isBlank()) { - selectedServerId = servers.firstOrNull { it.isSelected }?.id ?: servers.first().id - } else if (servers.none { it.id == selectedServerId }) { - selectedServerId = servers.first().id + selectedServerId = when { + selectedServerId.isBlank() -> servers.firstOrNull { it.isSelected }?.id ?: servers.first().id + servers.any { it.id == selectedServerId } -> selectedServerId + else -> servers.first().id } } } - LaunchedEffect(Unit) { delay(2200) showStartScreen = false } - - // Dismiss splash only after both: minimum display time AND correct server selected LaunchedEffect(selectedServerId) { if (selectedServerId.isNotEmpty()) { delay(1200) showStartScreen = false } } - AppTheme.systemIsDark = androidx.compose.foundation.isSystemInDarkTheme() + + AppTheme.systemIsDark = isSystemInDarkTheme() if (!showStartScreen) { MiuixTheme { + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + var lastRootTab by rememberSaveable { mutableIntStateOf(0) } + val rootTab = rootTabIndex(currentRoute) + LaunchedEffect(rootTab) { + if (rootTab != null) lastRootTab = rootTab + } + val selectedTab = rootTab ?: lastRootTab + val showBottomBar = isRootRoute(currentRoute) val glassBackdrop = rememberLayerBackdrop() val bgColor = if (AppTheme.isDark) Color(0xFF0F1115) else Color(0xFFF2F4F7) - + + val selectServer: (String) -> Unit = { id -> + selectedServerId = id + coroutineScope.launch { ServerRepository.setSelectedServer(id) } + } + val addServer: (EmbyServer, (String?) -> Unit) -> Unit = { server, onResult -> + coroutineScope.launch { + try { + val authResult = EmbyRepository.authenticate(server) + if (authResult.success) { + ServerRepository.addServer(server.copy(apiKey = authResult.accessToken, userId = authResult.userId)) + if (selectedServerId.isBlank()) selectedServerId = server.id + onResult(null) + } else { + onResult(authResult.errorMessage ?: "登录失败") + } + } catch (e: Exception) { + onResult("网络错误: " + (e.message ?: "未知")) + } + } + } + val updateServer: (EmbyServer, (String?) -> Unit) -> Unit = { server, onResult -> + coroutineScope.launch { + try { + val authResult = EmbyRepository.authenticate(server) + if (authResult.success) { + ServerRepository.updateServer(server.copy(apiKey = authResult.accessToken, userId = authResult.userId)) + onResult(null) + } else { + onResult(authResult.errorMessage ?: "登录失败") + } + } catch (e: Exception) { + onResult("网络错误: " + (e.message ?: "未知")) + } + } + } + + fun openRoot(route: String) { + if (navController.currentDestination?.route == route) return + navController.navigate(route) { + popUpTo(AppRoute.Home.pattern) { saveState = true } + launchSingleTop = true + restoreState = true + } + } + androidx.compose.runtime.CompositionLocalProvider(LocalGlassBackdrop provides glassBackdrop) { Box(Modifier.fillMaxSize().background(bgColor)) { - if (selectedTab == 2 && showIconLibraryManager) { - IconLibraryManagerScreen( - libraries = iconLibraries, - modifier = Modifier.fillMaxSize(), - onBack = { showIconLibraryManager = false }, - onAddIconLibrary = { name, url, icons, isDefault -> - val library = IconLibrary( - id = "lib_${System.currentTimeMillis()}", - name = name, - sourceUrl = url, - iconCount = icons.size, - isDefault = isDefault, - icons = icons, - ) - coroutineScope.launch { IconLibraryRepository.addLibrary(library) } - }, - onUpdateIconLibrary = { library -> coroutineScope.launch { IconLibraryRepository.updateLibrary(library) } }, - onDeleteIconLibrary = { id -> coroutineScope.launch { IconLibraryRepository.removeLibrary(id) } }, - onRefreshIconLibrary = { id -> coroutineScope.launch { IconLibraryRepository.refreshLibrary(id) } }, - ) - } else { - Box(Modifier.fillMaxSize()) { - Box(Modifier.fillMaxSize()) { - AnimatedContent( - targetState = selectedTab, - transitionSpec = { - val direction = if (targetState > initialState) 1 else -1 - (slideInHorizontally(tween(320)) { direction * it } + fadeIn(tween(260))).togetherWith( - slideOutHorizontally(tween(260)) { -direction * it } + fadeOut(tween(180)) - ) - }, - label = "MainTabTransition", - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopStart, - contentKey = { it }, - ) { tab -> - Column(Modifier.fillMaxSize()) { - if (!detailVisible) { - when (tab) { - 0 -> Spacer(Modifier.height(0.dp)) - 1 -> RecordsTopBar(recordsSegment) { recordsSegment = it } - 2 -> LibraryTopBar( - isGrid = libraryIsGrid, - onViewToggle = { libraryIsGrid = !libraryIsGrid }, - onAdd = { showLibraryAddDialog = true }, - onOpenIconLibraryManager = { showIconLibraryManager = true }, - ) - 3 -> { - if (showCacheDetail) { - Row( - Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 18.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - Modifier.size(34.dp).clip(CircleShape).background(GlassTokens.glassFill).border(0.5.dp, GlassTokens.glassBorder, CircleShape) - .clickable { showCacheDetail = false }, - contentAlignment = Alignment.Center, - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = GlassTokens.text, modifier = Modifier.size(19.dp)) - } - Spacer(Modifier.width(12.dp)) - Text("缓存管理", color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold) - } - } else if (showAboutDetail) { - Row( - Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 18.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - Modifier.size(34.dp).clip(CircleShape).background(GlassTokens.glassFill).border(0.5.dp, GlassTokens.glassBorder, CircleShape) - .clickable { showAboutDetail = false }, - contentAlignment = Alignment.Center, - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = GlassTokens.text, modifier = Modifier.size(19.dp)) - } - Spacer(Modifier.width(12.dp)) - Text("关于", color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold) - } - } else { - SettingsTopBar() - } - } - 4 -> SearchTopBar(searchQuery, { searchQuery = it }, { }) - } - } - - Box(Modifier.weight(1f).layerBackdrop(glassBackdrop)) { - androidx.compose.runtime.CompositionLocalProvider(LocalGlassBackdrop provides null) { - when (tab) { - 0 -> HomeScreen( - servers = servers, - selected = selectedServer, - onDetailVisibilityChanged = { detailVisible = it }, - onSelectServer = { id -> - selectedServerId = id - selectedTab = 0 - coroutineScope.launch { ServerRepository.setSelectedServer(id) } - }, - ) - 1 -> RecordsScreen(segment = recordsSegment, activeServer = selectedServer, onDetailVisibilityChanged = { detailVisible = it }) - 2 -> LibraryScreen( - servers = servers, - selectedServer = selectedServer, - onSelectServer = { id -> - selectedServerId = id - selectedTab = 0 - coroutineScope.launch { ServerRepository.setSelectedServer(id) } - }, - onAddServer = { server, onResult -> - coroutineScope.launch { - try { - val authResult = EmbyRepository.authenticate(server) - if (authResult.success) { - ServerRepository.addServer(server.copy(apiKey = authResult.accessToken, userId = authResult.userId)) - if (selectedServerId.isBlank()) selectedServerId = server.id - onResult(null) - } else onResult(authResult.errorMessage ?: "登录失败") - } catch (e: Exception) { onResult("网络错误: " + (e.message ?: "未知")) } - } - }, - onDeleteServer = { id -> - coroutineScope.launch { ServerRepository.deleteServer(id) } - if (selectedServerId == id) selectedServerId = "" - }, - onUpdateServer = { updated, onResult -> - coroutineScope.launch { - try { - val authResult = EmbyRepository.authenticate(updated) - if (authResult.success) { - ServerRepository.updateServer(updated.copy(apiKey = authResult.accessToken, userId = authResult.userId)) - onResult(null) - } else onResult(authResult.errorMessage ?: "登录失败") - } catch (e: Exception) { onResult("网络错误: " + (e.message ?: "未知")) } - } - }, - showAddDialog = showLibraryAddDialog, - onDismissAddDialog = { showLibraryAddDialog = false }, - isGrid = libraryIsGrid, - onChangeIconTarget = changeIconTarget, - onDismissChangeIcon = { changeIconTarget = null }, - onPickIcon = { server, iconUrl -> ServerRepository.setRuntimeIcon(server.id, iconUrl) }, - onRequestChangeIcon = { server -> changeIconTarget = server }, - showTopBar = false, - ) - 3 -> SettingsScreen( - servers = servers, - showCacheDetail = showCacheDetail, - onOpenCacheDetail = { showCacheDetail = true }, - onBackFromCache = { showCacheDetail = false }, - showAboutDetail = showAboutDetail, - onOpenAbout = { showAboutDetail = true }, - onBackFromAbout = { showAboutDetail = false }, - ) - 4 -> SearchScreen(query = searchQuery, activeServer = selectedServer, onDetailVisibilityChanged = { detailVisible = it }) - } - } - } - } - } - } - - if (!detailVisible) { - Box( - Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(bottom = 12.dp), - contentAlignment = Alignment.BottomCenter, - ) { - LiquidAppBottomBar( - selectedTab = selectedTab, - onSelect = { selectedTab = it }, - backdrop = glassBackdrop, + Box(Modifier.fillMaxSize().layerBackdrop(glassBackdrop)) { + androidx.compose.runtime.CompositionLocalProvider(LocalGlassBackdrop provides null) { + AppNavHost( + navController = navController, + servers = servers, + selectedServer = selectedServer, + iconLibraries = iconLibraries, + onSelectServer = selectServer, + onAddServer = addServer, + onDeleteServer = { id -> + coroutineScope.launch { ServerRepository.deleteServer(id) } + if (selectedServerId == id) selectedServerId = "" + }, + onUpdateServer = updateServer, + changeIconTarget = changeIconTarget, + onDismissChangeIcon = { changeIconTarget = null }, + onPickIcon = { server, iconUrl -> ServerRepository.setRuntimeIcon(server.id, iconUrl) }, + onRequestChangeIcon = { server -> changeIconTarget = server }, + onAddIconLibrary = { name, url, icons, isDefault -> + val library = IconLibrary( + id = "lib_${System.currentTimeMillis()}", + name = name, + sourceUrl = url, + iconCount = icons.size, + isDefault = isDefault, + icons = icons, ) - } - } + coroutineScope.launch { IconLibraryRepository.addLibrary(library) } + }, + onUpdateIconLibrary = { library -> + coroutineScope.launch { IconLibraryRepository.updateLibrary(library) } + }, + onDeleteIconLibrary = { id -> + coroutineScope.launch { IconLibraryRepository.removeLibrary(id) } + }, + onRefreshIconLibrary = { id -> + coroutineScope.launch { IconLibraryRepository.refreshLibrary(id) } + }, + ) + } + } + + AnimatedVisibility( + visible = showBottomBar, + modifier = Modifier.align(Alignment.BottomCenter), + enter = fadeIn(tween(180)) + slideInVertically(tween(180)) { it / 3 }, + exit = fadeOut(tween(180)) + slideOutVertically(tween(180)) { it / 3 }, + ) { + Box( + Modifier.fillMaxWidth().padding(bottom = 12.dp), + contentAlignment = Alignment.BottomCenter, + ) { + LiquidAppBottomBar( + selectedTab = selectedTab, + onSelect = { index -> + if (showBottomBar) { + val route = when (index) { + 0 -> AppRoute.Home.pattern + 1 -> AppRoute.Records.pattern + 2 -> AppRoute.Library.pattern + 3 -> AppRoute.Settings.pattern + else -> AppRoute.Search.pattern + } + openRoot(route) + } + }, + backdrop = glassBackdrop, + ) } } } } } -} + } } 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 6261aa3..b26566e 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt @@ -44,13 +44,11 @@ data class EmbyServer( get() { // Strip protocol prefix from host if user already included it var cleanHost = host.trim() - var effectiveProtocol = protocol - if (cleanHost.startsWith("https://")) { - effectiveProtocol = "https" - cleanHost = cleanHost.removePrefix("https://") - } else if (cleanHost.startsWith("http://")) { - effectiveProtocol = "http" - cleanHost = cleanHost.removePrefix("http://") + val effectiveProtocol = if (protocol.equals("http", ignoreCase = true)) "http" else "https" + if (cleanHost.startsWith("https://", ignoreCase = true)) { + cleanHost = cleanHost.substringAfter("://") + } else if (cleanHost.startsWith("http://", ignoreCase = true)) { + cleanHost = cleanHost.substringAfter("://") } // Strip trailing slashes and port if embedded in host cleanHost = cleanHost.trimEnd('/') 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 2c28ee8..9b7149e 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt @@ -173,6 +173,7 @@ object ServerRepository { return ServerEntity( server.id, server.name, + server.protocol, server.host, server.basePath, server.port, @@ -190,6 +191,7 @@ object ServerRepository { return EmbyServer( id = entity.id, name = entity.name, + protocol = entity.protocol ?: "https", host = entity.host, basePath = entity.basePath, port = entity.port, @@ -202,4 +204,4 @@ object ServerRepository { isSelected = entity.isSelected == 1, ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt new file mode 100644 index 0000000..ee43bfa --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -0,0 +1,363 @@ +package com.example.myapplication.navigation + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.background +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.material3.CircularProgressIndicator +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.example.myapplication.data.EmbyItem +import com.example.myapplication.data.EmbyRepository +import com.example.myapplication.data.EmbyServer +import com.example.myapplication.data.IconItem +import com.example.myapplication.data.IconLibrary +import com.example.myapplication.screens.AboutScreen +import com.example.myapplication.screens.AddServerScreen +import com.example.myapplication.screens.CacheManagementScreen +import com.example.myapplication.screens.HomeScreen +import com.example.myapplication.screens.IconLibraryManagerScreen +import com.example.myapplication.screens.LibraryScreen +import com.example.myapplication.screens.MediaDetailScreen +import com.example.myapplication.screens.RecordsScreen +import com.example.myapplication.screens.RecordsTopBar +import com.example.myapplication.screens.SearchScreen +import com.example.myapplication.screens.SearchTopBar +import com.example.myapplication.screens.SettingsScreen +import com.example.myapplication.screens.SettingsTopBar +import com.example.myapplication.screens.VideoPlayerScreen +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.basic.Text + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun AppNavHost( + navController: NavHostController, + servers: List, + selectedServer: EmbyServer?, + iconLibraries: List, + onSelectServer: (String) -> Unit, + onAddServer: (EmbyServer, (String?) -> Unit) -> Unit, + onDeleteServer: (String) -> Unit, + onUpdateServer: (EmbyServer, (String?) -> Unit) -> Unit, + changeIconTarget: EmbyServer?, + onDismissChangeIcon: () -> Unit, + onPickIcon: (EmbyServer, String) -> Unit, + onRequestChangeIcon: (EmbyServer) -> Unit, + onAddIconLibrary: (String, String, List, Boolean) -> Unit, + onUpdateIconLibrary: (IconLibrary) -> Unit, + onDeleteIconLibrary: (String) -> Unit, + onRefreshIconLibrary: (String) -> Unit, +) { + fun navigateOnce(route: String) { + if (navController.currentDestination?.route == route) return + navController.navigate(route) { launchSingleTop = true } + } + + val openDetail: (String) -> Unit = { itemId -> + selectedServer?.id?.let { serverId -> + val currentEntry = navController.currentBackStackEntry + if (currentEntry?.destination?.route == AppRoute.MediaDetail.pattern && + currentEntry.arguments?.getString("itemId") == itemId + ) return@let + navController.navigate(AppRoute.MediaDetail.create(serverId, itemId)) + } + } + + NavHost( + navController = navController, + startDestination = AppRoute.Home.pattern, + modifier = Modifier.fillMaxSize(), + enterTransition = { appEnterTransition() }, + exitTransition = { appExitTransition() }, + popEnterTransition = { appPopEnterTransition() }, + popExitTransition = { appPopExitTransition() }, + ) { + composable(AppRoute.Home.pattern) { + HomeScreen( + servers = servers, + selected = selectedServer, + onSelectServer = onSelectServer, + onOpenDetail = openDetail, + ) + } + + composable(AppRoute.Records.pattern) { + var segment by rememberSaveable { mutableStateOf(0) } + Column(Modifier.fillMaxSize()) { + RecordsTopBar(selected = segment, onSelect = { segment = it }) + Box(Modifier.weight(1f)) { + RecordsScreen( + segment = segment, + activeServer = selectedServer, + onOpenDetail = openDetail, + ) + } + } + } + + composable(AppRoute.Library.pattern) { + var isGrid by rememberSaveable { mutableStateOf(false) } + LibraryScreen( + servers = servers, + selectedServer = selectedServer, + onSelectServer = onSelectServer, + onDeleteServer = onDeleteServer, + onUpdateServer = onUpdateServer, + isGrid = isGrid, + onChangeIconTarget = changeIconTarget, + onDismissChangeIcon = onDismissChangeIcon, + onPickIcon = onPickIcon, + onRequestChangeIcon = onRequestChangeIcon, + onViewToggle = { isGrid = !isGrid }, + onAdd = { navigateOnce(AppRoute.AddServer.pattern) }, + onOpenIconLibraryManager = { navigateOnce(AppRoute.IconLibraryManager.pattern) }, + ) + } + + composable(AppRoute.Settings.pattern) { + Column(Modifier.fillMaxSize()) { + SettingsTopBar() + Box(Modifier.weight(1f)) { + SettingsScreen( + servers = servers, + onOpenCacheDetail = { navigateOnce(AppRoute.CacheManagement.pattern) }, + onOpenAbout = { navigateOnce(AppRoute.About.pattern) }, + ) + } + } + } + + composable(AppRoute.Search.pattern) { + var query by rememberSaveable { mutableStateOf("") } + Column(Modifier.fillMaxSize()) { + SearchTopBar( + query = query, + onQueryChange = { query = it }, + onGearClick = {}, + ) + Box(Modifier.weight(1f)) { + SearchScreen( + query = query, + activeServer = selectedServer, + onOpenDetail = openDetail, + ) + } + } + } + + composable( + route = AppRoute.MediaDetail.pattern, + arguments = listOf( + navArgument("serverId") { type = NavType.StringType }, + navArgument("itemId") { type = NavType.StringType }, + ), + ) { entry -> + MediaDetailDestination( + server = servers.firstOrNull { it.id == entry.arguments?.getString("serverId") }, + itemId = entry.arguments?.getString("itemId").orEmpty(), + onClose = { navController.popBackStack() }, + onPlay = { itemId, seriesId, seasonId -> + val serverId = entry.arguments?.getString("serverId").orEmpty() + val currentEntry = navController.currentBackStackEntry + val alreadyPlaying = currentEntry?.destination?.route == AppRoute.VideoPlayer.pattern && + currentEntry.arguments?.getString("itemId") == itemId + if (!alreadyPlaying) { + navController.navigate(AppRoute.VideoPlayer.create(serverId, itemId, seriesId, seasonId)) + } + }, + ) + } + + composable( + route = AppRoute.VideoPlayer.pattern, + arguments = listOf( + navArgument("serverId") { type = NavType.StringType }, + navArgument("itemId") { type = NavType.StringType }, + navArgument("seriesId") { type = NavType.StringType }, + navArgument("seasonId") { type = NavType.StringType }, + ), + ) { entry -> + PlayerDestination( + server = servers.firstOrNull { it.id == entry.arguments?.getString("serverId") }, + itemId = entry.arguments?.getString("itemId").orEmpty(), + seriesId = entry.arguments?.getString("seriesId").routeArgumentOrNull(), + seasonId = entry.arguments?.getString("seasonId").routeArgumentOrNull(), + onClose = { navController.popBackStack() }, + ) + } + + composable(AppRoute.IconLibraryManager.pattern) { + IconLibraryManagerScreen( + libraries = iconLibraries, + modifier = Modifier.fillMaxSize(), + onBack = { navController.popBackStack() }, + onAddIconLibrary = onAddIconLibrary, + onUpdateIconLibrary = onUpdateIconLibrary, + onDeleteIconLibrary = onDeleteIconLibrary, + onRefreshIconLibrary = onRefreshIconLibrary, + ) + } + + composable(AppRoute.CacheManagement.pattern) { + CacheManagementScreen(onBack = { navController.popBackStack() }) + } + + composable(AppRoute.About.pattern) { + AboutScreen(onBack = { navController.popBackStack() }) + } + + composable(AppRoute.AddServer.pattern) { + AddServerScreen( + onDismiss = { navController.popBackStack() }, + onConfirm = onAddServer, + ) + } + } +} + +@Composable +private fun MediaDetailDestination( + server: EmbyServer?, + itemId: String, + onClose: () -> Unit, + onPlay: (String, String?, String?) -> Unit, +) { + var item by remember(server?.id, itemId) { mutableStateOf(null) } + var loading by remember(server?.id, itemId) { mutableStateOf(true) } + + LaunchedEffect(server?.id, itemId) { + loading = true + item = if (server == null || server.apiKey.isBlank() || itemId.isBlank()) { + null + } else { + EmbyRepository.getItem(server, itemId) + } + loading = false + } + + when { + item != null -> MediaDetailScreen(item = item!!, server = server, onClose = onClose, onPlay = onPlay) + loading -> DestinationLoading() + else -> DestinationMessage("详情加载失败", background = Color.Transparent) + } +} + +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +@Composable +private fun PlayerDestination( + server: EmbyServer?, + itemId: String, + seriesId: String?, + seasonId: String?, + onClose: () -> Unit, +) { + val playbackScope = rememberCoroutineScope() + var item by remember(server?.id, itemId) { mutableStateOf(null) } + var itemLoading by remember(server?.id, itemId) { mutableStateOf(true) } + var episodes by remember(server?.id, seriesId, seasonId) { mutableStateOf>(emptyList()) } + var selectedItem by remember(server?.id, itemId) { mutableStateOf(null) } + var playbackUrl by remember(server?.id, itemId) { mutableStateOf(null) } + var playbackLoading by remember(server?.id, itemId) { mutableStateOf(false) } + var playbackError by remember(server?.id, itemId) { mutableStateOf(null) } + + LaunchedEffect(server?.id, itemId) { + itemLoading = true + item = if (server == null || server.apiKey.isBlank() || itemId.isBlank()) { + null + } else { + EmbyRepository.getItem(server, itemId) + } + itemLoading = false + } + + LaunchedEffect(server?.id, seriesId, seasonId) { + episodes = if (server == null || seriesId.isNullOrBlank() || seasonId.isNullOrBlank()) { + emptyList() + } else { + EmbyRepository.getEpisodes(server, seriesId, seasonId) + } + } + + val target = selectedItem ?: item + LaunchedEffect(server?.id, target?.id) { + if (server == null || server.apiKey.isBlank() || target == null) return@LaunchedEffect + playbackLoading = true + playbackError = null + playbackUrl = EmbyRepository.resolvePlaybackUrl(server, target) + playbackLoading = false + if (playbackUrl.isNullOrBlank()) playbackError = "获取播放地址失败" + } + + when { + target != null -> VideoPlayerScreen( + title = target.name, + playbackUrl = playbackUrl, + initialPositionMs = target.playbackPositionMs, + isLoading = itemLoading || playbackLoading, + errorText = playbackError, + episodes = episodes, + currentEpisodeId = target.id, + onSelectEpisode = { episode -> + selectedItem = episode + playbackUrl = null + playbackError = null + }, + onClose = onClose, + onPlaybackStarted = { positionMs -> + server?.let { currentServer -> + playbackScope.launch { + EmbyRepository.reportPlaybackStarted(currentServer, target, positionMs) + } + } + }, + onPlaybackProgress = { positionMs, paused -> + server?.let { currentServer -> + playbackScope.launch { + EmbyRepository.reportPlaybackProgress(currentServer, target, positionMs, paused) + } + } + }, + onPlaybackStopped = { positionMs -> + server?.let { currentServer -> + playbackScope.launch { + EmbyRepository.reportPlaybackStopped(currentServer, target, positionMs) + } + } + }, + ) + itemLoading -> DestinationLoading(background = Color.Black) + else -> DestinationMessage("播放资源加载失败", background = Color.Black) + } +} + +@Composable +private fun DestinationLoading(background: Color = Color.Transparent) = Box( + Modifier.fillMaxSize().background(background), + contentAlignment = Alignment.Center, +) { + CircularProgressIndicator(color = Color(0xFF3B82F6)) +} + +@Composable +private fun DestinationMessage(message: String, background: Color = Color.Transparent): Unit = Box( + Modifier.fillMaxSize().background(background), + contentAlignment = Alignment.Center, +) { + Text(text = message, color = Color.White) +} diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt new file mode 100644 index 0000000..f34989f --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppRoute.kt @@ -0,0 +1,41 @@ +package com.example.myapplication.navigation + +import android.net.Uri + +sealed interface AppRoute { + val pattern: String + + data object Home : AppRoute { override val pattern = "home" } + data object Records : AppRoute { override val pattern = "records" } + data object Library : AppRoute { override val pattern = "library" } + data object Settings : AppRoute { override val pattern = "settings" } + data object Search : AppRoute { override val pattern = "search" } + data object MediaDetail : AppRoute { + override val pattern = "detail/{serverId}/{itemId}" + fun create(serverId: String, itemId: String) = "detail/${serverId.encode()}/${itemId.encode()}" + } + data object VideoPlayer : AppRoute { + override val pattern = "player/{serverId}/{itemId}/{seriesId}/{seasonId}" + fun create(serverId: String, itemId: String, seriesId: String?, seasonId: String?) = + "player/${serverId.encode()}/${itemId.encode()}/${(seriesId ?: "-").encode()}/${(seasonId ?: "-").encode()}" + } + data object IconLibraryManager : AppRoute { override val pattern = "library/icons" } + data object CacheManagement : AppRoute { override val pattern = "settings/cache" } + data object About : AppRoute { override val pattern = "settings/about" } + data object AddServer : AppRoute { override val pattern = "library/add-server" } +} + +private fun String.encode(): String = Uri.encode(this) + +fun rootTabIndex(route: String?): Int? = when { + route == AppRoute.Home.pattern -> 0 + route == AppRoute.Records.pattern -> 1 + route == AppRoute.Library.pattern -> 2 + route == AppRoute.Settings.pattern -> 3 + route == AppRoute.Search.pattern -> 4 + else -> null +} + +fun isRootRoute(route: String?): Boolean = rootTabIndex(route) != null + +fun String?.routeArgumentOrNull(): String? = this?.takeUnless { it.isBlank() || it == "-" } diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt b/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt new file mode 100644 index 0000000..0381222 --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt @@ -0,0 +1,75 @@ +package com.example.myapplication.navigation + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.tween +import androidx.navigation.NavBackStackEntry + +object NavigationMotionTokens { + const val detailPushDuration = 320 + const val detailPopDuration = 280 + const val tabDuration = 250 + const val detailParallax = 0.20f + const val tabOffset = 0.25f + + val pushEasing = CubicBezierEasing(0.20f, 0.82f, 0.24f, 1f) + val popEasing = CubicBezierEasing(0.32f, 0f, 0.68f, 1f) + val tabEasing = CubicBezierEasing(0.22f, 0.78f, 0.28f, 1f) +} + +fun AnimatedContentTransitionScope.appEnterTransition(): EnterTransition { + val initialRoute = initialState.destination.route + val targetRoute = targetState.destination.route + val initialTab = rootTabIndex(initialRoute) + val targetTab = rootTabIndex(targetRoute) + return if (initialTab != null && targetTab != null) { + val direction = if (targetTab >= initialTab) 1 else -1 + slideInHorizontally( + animationSpec = tween(NavigationMotionTokens.tabDuration, easing = NavigationMotionTokens.tabEasing), + ) { width -> direction * (width * NavigationMotionTokens.tabOffset).toInt() } + + fadeIn(tween(NavigationMotionTokens.tabDuration)) + } else { + slideInHorizontally( + animationSpec = tween(NavigationMotionTokens.detailPushDuration, easing = NavigationMotionTokens.pushEasing), + ) { width -> width } + + fadeIn(tween(220)) + } +} + +fun AnimatedContentTransitionScope.appExitTransition(): ExitTransition { + val initialRoute = initialState.destination.route + val targetRoute = targetState.destination.route + val initialTab = rootTabIndex(initialRoute) + val targetTab = rootTabIndex(targetRoute) + return if (initialTab != null && targetTab != null) { + val direction = if (targetTab >= initialTab) 1 else -1 + slideOutHorizontally( + animationSpec = tween(NavigationMotionTokens.tabDuration, easing = NavigationMotionTokens.tabEasing), + ) { width -> -direction * (width * NavigationMotionTokens.tabOffset).toInt() } + + fadeOut(tween(190)) + } else { + slideOutHorizontally( + animationSpec = tween(NavigationMotionTokens.detailPushDuration, easing = NavigationMotionTokens.pushEasing), + ) { width -> -(width * NavigationMotionTokens.detailParallax).toInt() } + + fadeOut(tween(220)) + } +} + +fun AnimatedContentTransitionScope.appPopEnterTransition(): EnterTransition = + slideInHorizontally( + animationSpec = tween(NavigationMotionTokens.detailPopDuration, easing = NavigationMotionTokens.popEasing), + ) { width -> -(width * NavigationMotionTokens.detailParallax).toInt() } + + fadeIn(tween(220)) + +fun AnimatedContentTransitionScope.appPopExitTransition(): ExitTransition = + slideOutHorizontally( + animationSpec = tween(NavigationMotionTokens.detailPopDuration, easing = NavigationMotionTokens.popEasing), + ) { width -> width } + + fadeOut(tween(220)) 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 8bfa64a..6953d53 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -79,7 +79,6 @@ import androidx.compose.foundation.pager.rememberPagerState import kotlinx.coroutines.async 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 @@ -519,18 +518,12 @@ private fun MyMediaGrid(libraries: List, serverUrl: String, onS fun HomeScreen( servers: List = emptyList(), selected: EmbyServer? = null, - onDetailVisibilityChanged: (Boolean) -> Unit = {}, onSelectServer: (String) -> Unit = {}, + onOpenDetail: (String) -> Unit = {}, hazeState: HazeState? = null, ) { val activeServer = remember(servers, selected) { selected ?: servers.firstOrNull { it.apiKey.isNotBlank() } } - var detailItem by remember { mutableStateOf(null) } - BackHandler(enabled = detailItem != null) { detailItem = null } - LaunchedEffect(detailItem != null) { - onDetailVisibilityChanged(detailItem != null) - } - var libraries by remember { mutableStateOf>(emptyList()) } var resumeItems by remember { mutableStateOf>(emptyList()) } var latestItems by remember { mutableStateOf>(emptyList()) } @@ -604,7 +597,7 @@ fun HomeScreen( serverUrl = server.fullUrl, pagerState = heroPagerState, immersiveBackground = immersiveBackground, - onItemClick = { detailItem = it } + onItemClick = { onOpenDetail(it.id) } ) } } @@ -627,7 +620,7 @@ fun HomeScreen( SectionTitle(title = "继续观看") LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { items(resumeItems) { item -> - ContinueWatchingPoster(item, server.fullUrl) { detailItem = it } + ContinueWatchingPoster(item, server.fullUrl) { onOpenDetail(it.id) } } } } @@ -640,7 +633,7 @@ fun HomeScreen( } libraries.forEach { folder -> - LazyLibrarySection(folder, server) { detailItem = it } + LazyLibrarySection(folder, server) { onOpenDetail(it.id) } } } } @@ -648,23 +641,11 @@ fun HomeScreen( } } - if (detailItem == null) { - HomeOverlayTopBar( - name = activeServer?.name ?: "未连接", - iconUrl = activeServer?.iconUrl ?: "", - modifier = Modifier.align(Alignment.TopCenter), - ) - } - - activeServer?.let { server -> - detailItem?.let { item -> - MediaDetailScreen( - item = item, - server = server, - onClose = { detailItem = null }, - ) - } - } + HomeOverlayTopBar( + name = activeServer?.name ?: "未连接", + iconUrl = activeServer?.iconUrl ?: "", + modifier = Modifier.align(Alignment.TopCenter), + ) } } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt index 60a981c..ebe71cc 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -22,6 +22,7 @@ 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.layout.navigationBarsPadding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.layout.aspectRatio @@ -31,6 +32,7 @@ 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.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -39,7 +41,6 @@ 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 @@ -56,9 +57,14 @@ 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.focus.focusRequester import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -68,12 +74,14 @@ 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.semantics.Role 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.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.GridView import androidx.compose.material.icons.filled.ViewAgenda import androidx.compose.material.icons.filled.PlayArrow @@ -93,10 +101,14 @@ 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.LiquidGlassPopupOverlay import com.example.myapplication.ui.LiquidGlassTopBar import com.example.myapplication.ui.bottomBarContentPadding import coil.compose.AsyncImage import coil.request.ImageRequest +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -141,8 +153,7 @@ fun LibraryTopBar( /* ------------------------------------------------------------------ */ @Composable -private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit) { - var showPopup by remember { mutableStateOf(false) } +private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { 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 } } @@ -181,8 +192,7 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC } } 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(imageVector = Icons.Outlined.MoreHoriz, contentDescription = "\u64CD\u4F5C", tint = GlassTokens.subtext, modifier = Modifier.size(32.dp).onGloballyPositioned(onMorePositioned).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onMoreClick() }.padding(4.dp)) } } } @@ -193,8 +203,7 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC /* ------------------------------------------------------------------ */ @Composable -private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit) { - var showPopup by remember { mutableStateOf(false) } +private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { 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 } } @@ -238,8 +247,7 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, } } 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() }) } + Icon(imageVector = Icons.Outlined.MoreHoriz, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(28.dp).onGloballyPositioned(onMorePositioned).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onMoreClick() }.padding(4.dp)) } } Box(Modifier.height(22.dp)) { @@ -265,11 +273,8 @@ fun LibraryScreen( servers: List, 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 = {}, @@ -281,14 +286,15 @@ fun LibraryScreen( showTopBar: Boolean = true, ) { var editingServer by remember { mutableStateOf(null) } + var popupServer by remember { mutableStateOf(null) } + var popupAnchor by remember { mutableStateOf(null) } + var popupVisible by remember { mutableStateOf(false) } + var rootOrigin by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } + val libraryBackdrop = rememberLayerBackdrop() - if (showAddDialog) { - AddServerDialog(onDismiss = onDismissAddDialog, onConfirm = { srv, onResult -> - onAddServer(srv, onResult) - }) - } if (editingServer != null) { AddServerDialog( + backdrop = libraryBackdrop, initial = editingServer, onDismiss = { editingServer = null }, onConfirm = { updated, onResult -> @@ -303,7 +309,8 @@ fun LibraryScreen( onPick = { srv, url -> onPickIcon(srv, url); onDismissChangeIcon() }, ) } - Box(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().onGloballyPositioned { rootOrigin = it.positionInRoot() }) { + Box(Modifier.fillMaxSize().layerBackdrop(libraryBackdrop)) { if (servers.isEmpty()) { Box( @@ -353,6 +360,14 @@ fun LibraryScreen( onChangeIcon = { onRequestChangeIcon(server) }, onDelete = { onDeleteServer(server.id) }, onEdit = { editingServer = server }, + onMorePositioned = { coordinates -> + if (popupServer?.id == server.id) popupAnchor = coordinates.boundsInRoot() + }, + onMoreClick = { + popupAnchor = null + popupServer = server + popupVisible = true + }, ) } else { ServerCard( @@ -361,6 +376,14 @@ fun LibraryScreen( onChangeIcon = { onRequestChangeIcon(server) }, onDelete = { onDeleteServer(server.id) }, onEdit = { editingServer = server }, + onMorePositioned = { coordinates -> + if (popupServer?.id == server.id) popupAnchor = coordinates.boundsInRoot() + }, + onMoreClick = { + popupAnchor = null + popupServer = server + popupVisible = true + }, ) } } @@ -376,6 +399,41 @@ fun LibraryScreen( modifier = Modifier.align(Alignment.TopCenter), ) } + + } + + val currentPopupServer = popupServer + val currentPopupAnchor = popupAnchor + if (currentPopupServer != null && currentPopupAnchor != null) { + LiquidGlassPopupOverlay( + anchorBounds = currentPopupAnchor, + rootOrigin = rootOrigin, + backdrop = libraryBackdrop, + visible = popupVisible, + onDismissRequest = { popupVisible = false }, + onClosed = { + popupServer = null + popupAnchor = null + }, + ) { + GlassPopupItem( + text = "\u7F16\u8F91", + icon = Icons.Outlined.Edit, + onClick = { popupVisible = false; editingServer = currentPopupServer }, + ) + GlassPopupItem( + text = "\u66F4\u6362\u56FE\u6807", + icon = Icons.Outlined.Image, + onClick = { popupVisible = false; onRequestChangeIcon(currentPopupServer) }, + ) + GlassPopupItem( + text = "\u5220\u9664", + icon = Icons.Outlined.Delete, + tint = offlineRed, + onClick = { popupVisible = false; onDeleteServer(currentPopupServer.id) }, + ) + } + } } } @@ -384,7 +442,7 @@ fun LibraryScreen( /* ------------------------------------------------------------------ */ @Composable -fun IconLibraryManagerScreen( +internal fun IconLibraryManagerScreenContent( libraries: List, modifier: Modifier = Modifier, onBack: () -> Unit = {}, @@ -1412,7 +1470,68 @@ private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: /* ------------------------------------------------------------------ */ @Composable -private fun AddServerDialog(initial: EmbyServer? = null, onDismiss: () -> Unit, onConfirm: (EmbyServer, (String?) -> Unit) -> Unit) { +fun AddServerScreen( + onDismiss: () -> Unit, + onConfirm: (EmbyServer, (String?) -> Unit) -> Unit, +) { + val pageBackground = if (GlassTokens.isDark) Color(0xFF0F1115) else Color(0xFFF2F4F7) + val buttonBackground = if (GlassTokens.isDark) Color(0xFF25272C) else Color.White + Column(Modifier.fillMaxSize().background(pageBackground)) { + Row( + Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 18.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.size(36.dp).clip(CircleShape).background(buttonBackground) + .border(1.dp, if (GlassTokens.isDark) Color(0xFF44474F) else Color(0xFFD1D5DB), CircleShape) + .clickable(onClick = onDismiss), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "\u8FD4\u56DE", tint = GlassTokens.text, modifier = Modifier.size(20.dp)) + } + Spacer(Modifier.width(12.dp)) + Text("\u6DFB\u52A0\u670D\u52A1\u5668", color = GlassTokens.text, fontSize = 22.sp, fontWeight = FontWeight.Bold) + } + ServerFormContent( + initial = null, + onDismiss = onDismiss, + onConfirm = onConfirm, + useGlass = false, + showHeader = false, + modifier = Modifier.fillMaxWidth().weight(1f).verticalScroll(rememberScrollState()) + .navigationBarsPadding().padding(horizontal = 18.dp, vertical = 8.dp), + ) + } +} + +@Composable +private fun AddServerDialog( + backdrop: Backdrop? = null, + initial: EmbyServer? = null, + onDismiss: () -> Unit, + onConfirm: (EmbyServer, (String?) -> Unit) -> Unit, +) { + GlassDialog(backdrop = backdrop, onDismissRequest = onDismiss) { + ServerFormContent( + initial = initial, + onDismiss = onDismiss, + onConfirm = onConfirm, + useGlass = true, + showHeader = true, + modifier = Modifier.width(300.dp).verticalScroll(rememberScrollState()), + ) + } +} + +@Composable +private fun ServerFormContent( + initial: EmbyServer?, + onDismiss: () -> Unit, + onConfirm: (EmbyServer, (String?) -> Unit) -> Unit, + useGlass: Boolean, + showHeader: Boolean, + modifier: Modifier = Modifier, +) { var name by remember { mutableStateOf(TextFieldValue(initial?.name ?: "")) } var host by remember { mutableStateOf(TextFieldValue(initial?.host ?: "")) } var port by remember { mutableStateOf(TextFieldValue(initial?.port ?: "")) } @@ -1424,21 +1543,41 @@ private fun AddServerDialog(initial: EmbyServer? = null, onDismiss: () -> Unit, var authError by remember { mutableStateOf("") } var showPassword by remember { mutableStateOf(false) } var showValidation by remember { mutableStateOf(false) } + var useHttps by remember { mutableStateOf(initial?.protocol?.equals("https", ignoreCase = true) ?: true) } 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), - ) { + fun submit() { + if (isSaving) return + 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(), + protocol = if (useHttps) "https" else "http", + 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() + } + } + } + } + + Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (showHeader) { Row(verticalAlignment = Alignment.CenterVertically) { Box( Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)) @@ -1450,99 +1589,98 @@ private fun AddServerDialog(initial: EmbyServer? = null, onDismiss: () -> Unit, 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, + } + ServerFormInput(useGlass = useGlass, label = "\u540D\u79F0", value = name, placeholder = "\u6211\u7684 Emby", onValueChange = { name = it }) + ServerFormInput( + useGlass = useGlass, + 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(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("HTTPS", color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium) + Text( + if (useHttps) "\u4F7F\u7528 https:// \u8BF7\u6C42" else "\u4F7F\u7528 http:// \u8BF7\u6C42", + color = GlassTokens.subtext, + fontSize = 12.sp, ) } - 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, + ProtocolSwitch(checked = useHttps, onCheckedChange = { useHttps = it }) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ServerFormInput( + useGlass = useGlass, + label = "\u8DEF\u5F84", + value = basePath, + placeholder = "/emby", + onValueChange = { basePath = it }, + modifier = Modifier.weight(1f), ) - 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 }, - ) - }, + ServerFormInput( + useGlass = useGlass, + 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 = "\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)) { + } + ServerFormInput( + useGlass = useGlass, + 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, + ) + ServerFormInput( + useGlass = useGlass, + 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 }, + ) + }, + ) + ServerFormInput(useGlass = useGlass, 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)) { + if (useGlass) { 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() - } - } - } - } - }, + onClick = { submit() }, + modifier = Modifier.weight(1f), + accent = true, + enabled = !isSaving, + ) + } else { + PlainButton(text = "\u53D6\u6D88", onClick = onDismiss, modifier = Modifier.weight(1f)) + PlainButton( + text = if (isSaving) "\u4FDD\u5B58\u4E2D..." else "\u4FDD\u5B58", + onClick = { submit() }, modifier = Modifier.weight(1f), accent = true, enabled = !isSaving, @@ -1551,3 +1689,130 @@ private fun AddServerDialog(initial: EmbyServer? = null, onDismiss: () -> Unit, } } } + +@Composable +private fun ServerFormInput( + useGlass: Boolean, + label: String, + value: TextFieldValue, + placeholder: String, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + visualTransformation: VisualTransformation = VisualTransformation.None, + trailingContent: (@Composable () -> Unit)? = null, + isError: Boolean = false, + errorMessage: String? = null, + focusRequester: FocusRequester? = null, +) { + if (useGlass) { + GlassInputField( + label = label, + value = value, + placeholder = placeholder, + onValueChange = onValueChange, + modifier = modifier, + visualTransformation = visualTransformation, + trailingContent = trailingContent, + isError = isError, + errorMessage = errorMessage, + focusRequester = focusRequester, + ) + } else { + PlainInputField( + label = label, + value = value, + placeholder = placeholder, + onValueChange = onValueChange, + modifier = modifier, + visualTransformation = visualTransformation, + trailingContent = trailingContent, + isError = isError, + focusRequester = focusRequester, + ) + } +} + +@Composable +private fun PlainInputField( + label: String, + value: TextFieldValue, + placeholder: String, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + visualTransformation: VisualTransformation = VisualTransformation.None, + trailingContent: (@Composable () -> Unit)? = null, + isError: Boolean = false, + focusRequester: FocusRequester? = null, +) { + val shape = RoundedCornerShape(12.dp) + val fieldBackground = if (GlassTokens.isDark) Color(0xFF24262B) else Color.White + Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(label, fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) + Box( + Modifier.fillMaxWidth().clip(shape).background(fieldBackground) + .border(1.dp, if (isError) GlassTokens.danger else if (GlassTokens.isDark) Color(0xFF44474F) else Color(0xFFD1D5DB), shape) + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 15.sp), + modifier = Modifier.fillMaxWidth().then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier), + visualTransformation = visualTransformation, + decorationBox = { inner -> + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { + if (value.text.isEmpty()) Text(placeholder, color = GlassTokens.subtext2, fontSize = 15.sp) + inner() + } + if (trailingContent != null) { + Spacer(Modifier.width(8.dp)) + trailingContent() + } + } + }, + ) + } + } +} + +@Composable +private fun PlainButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + accent: Boolean = false, +) { + val shape = RoundedCornerShape(12.dp) + val background = if (accent) GlassTokens.accent else if (GlassTokens.isDark) Color(0xFF25272C) else Color.White + Box( + modifier.height(48.dp).clip(shape).background(background) + .border(1.dp, if (accent) GlassTokens.accent else if (GlassTokens.isDark) Color(0xFF44474F) else Color(0xFFD1D5DB), shape) + .clickable(enabled = enabled, onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text(text, color = if (accent) Color.White else GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +private fun ProtocolSwitch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + val shape = RoundedCornerShape(15.dp) + val trackColor = if (checked) GlassTokens.accent else if (GlassTokens.isDark) Color(0xFF48484A) else Color(0xFFD1D1D6) + Box( + Modifier.width(50.dp).height(30.dp).clip(shape).background(trackColor) + .border(1.dp, if (checked) GlassTokens.accent else GlassTokens.glassBorder, shape) + .toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange) + .padding(3.dp), + ) { + Box( + Modifier.size(24.dp).align(if (checked) Alignment.CenterEnd else Alignment.CenterStart) + .clip(CircleShape).background(Color.White), + ) + } +} diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt index f2ae875..bbe0b88 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -40,7 +40,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment @@ -71,7 +71,6 @@ import com.example.myapplication.data.EmbyItem import com.example.myapplication.data.EmbyRepository import com.example.myapplication.data.EmbyServer import top.yukonga.miuix.kmp.basic.Text -import kotlinx.coroutines.launch import java.util.Locale private val LocalDetailText = staticCompositionLocalOf { Color.White } @@ -85,22 +84,18 @@ fun MediaDetailScreen( item: EmbyItem, server: EmbyServer?, onClose: () -> Unit, + onPlay: (itemId: String, seriesId: String?, seasonId: String?) -> Unit = { _, _, _ -> }, ) { val context = LocalContext.current - val playbackScope = rememberCoroutineScope() var detail by remember(item.id) { mutableStateOf(item) } var loadError by remember(item.id) { mutableStateOf(null) } var refreshing by remember(item.id) { mutableStateOf(false) } var seasons by remember(item.id) { mutableStateOf>(emptyList()) } - var selectedSeasonId by remember(item.id) { mutableStateOf(null) } + var selectedSeasonId by rememberSaveable(item.id) { mutableStateOf(null) } var episodes by remember(selectedSeasonId) { mutableStateOf>(emptyList()) } - var selectedEpisodeId by remember(item.id) { mutableStateOf(null) } + var selectedEpisodeId by rememberSaveable(item.id) { mutableStateOf(null) } var showAllEpisodes by remember(item.id) { mutableStateOf(false) } - var playingItem by remember(item.id) { mutableStateOf(null) } - var playbackUrl by remember(item.id) { mutableStateOf(null) } - var playbackLoading by remember(item.id) { mutableStateOf(false) } - var playbackError by remember(item.id) { mutableStateOf(null) } LaunchedEffect(episodes) { if (selectedEpisodeId == null || episodes.none { it.id == selectedEpisodeId }) { @@ -138,24 +133,6 @@ fun MediaDetailScreen( episodes = EmbyRepository.getEpisodes(currentServer, seriesId, seasonId) } - LaunchedEffect(playingItem?.id, server?.id, server?.apiKey) { - val target = playingItem - val currentServer = server - if (target == null || currentServer == null || currentServer.apiKey.isBlank()) return@LaunchedEffect - - playbackLoading = true - playbackError = null - playbackUrl = try { - EmbyRepository.resolvePlaybackUrl(currentServer, target) - } catch (e: Exception) { - null - } - playbackLoading = false - if (playbackUrl.isNullOrBlank()) { - playbackError = "获取播放地址失败" - } - } - val seriesFallbackImageUrl = EmbyRepository.buildImageUrl(server?.fullUrl.orEmpty(), detail, maxWidth = 640) ?: EmbyRepository.buildBackdropUrl(server?.fullUrl.orEmpty(), detail, maxWidth = 640) val immersive = rememberDetailImmersiveBackground(detail, server?.fullUrl.orEmpty()) @@ -214,7 +191,11 @@ fun MediaDetailScreen( if (playTarget == null) { Toast.makeText(context, "剧集尚未加载完成", Toast.LENGTH_SHORT).show() } else { - playingItem = playTarget + onPlay( + playTarget.id, + detail.id.takeIf { detail.type == "Series" }, + selectedSeasonId.takeIf { detail.type == "Series" }, + ) } } } @@ -368,50 +349,6 @@ fun MediaDetailScreen( } } - playingItem?.let { playTarget -> - VideoPlayerScreen( - title = playTarget.name, - playbackUrl = playbackUrl, - initialPositionMs = playTarget.playbackPositionMs, - isLoading = playbackLoading, - errorText = playbackError, - episodes = episodes, - currentEpisodeId = if (detail.type == "Series") selectedEpisodeId else null, - onSelectEpisode = { episode -> - selectedEpisodeId = episode.id - playingItem = episode - playbackUrl = null - playbackError = null - }, - onClose = { - playingItem = null - playbackUrl = null - playbackError = null - }, - onPlaybackStarted = { positionMs -> - server?.let { currentServer -> - playbackScope.launch { - EmbyRepository.reportPlaybackStarted(currentServer, playTarget, positionMs) - } - } - }, - onPlaybackProgress = { positionMs, paused -> - server?.let { currentServer -> - playbackScope.launch { - EmbyRepository.reportPlaybackProgress(currentServer, playTarget, positionMs, paused) - } - } - }, - onPlaybackStopped = { positionMs -> - server?.let { currentServer -> - playbackScope.launch { - EmbyRepository.reportPlaybackStopped(currentServer, playTarget, positionMs) - } - } - }, - ) - } - // Pinned above the scrolling content so it stays visible while browsing episodes. Box( Modifier diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index 78e0dc6..698839f 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -1,6 +1,5 @@ package com.example.myapplication.screens -import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween @@ -93,16 +92,10 @@ fun RecordsTopBar( fun RecordsScreen( segment: Int, activeServer: EmbyServer? = null, - onDetailVisibilityChanged: (Boolean) -> Unit = {}, + onOpenDetail: (String) -> Unit = {}, ) { var favorites by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(false) } - var selectedFavorite by remember { mutableStateOf(null) } - - BackHandler(enabled = selectedFavorite != null) { selectedFavorite = null } - LaunchedEffect(selectedFavorite != null) { - onDetailVisibilityChanged(selectedFavorite != null) - } LaunchedEffect(activeServer?.id) { if (activeServer != null && activeServer.apiKey.isNotBlank()) { @@ -147,20 +140,13 @@ fun RecordsScreen( FavoriteMediaCard( item = item, serverUrl = activeServer?.fullUrl.orEmpty(), - onClick = { selectedFavorite = item }, + onClick = { onOpenDetail(item.id) }, ) } } } } - selectedFavorite?.let { item -> - MediaDetailScreen( - item = item, - server = activeServer, - onClose = { selectedFavorite = null }, - ) - } } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt index e5c81a3..ca13eb1 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt @@ -81,11 +81,10 @@ fun SearchTopBar( fun SearchScreen( query: String, activeServer: EmbyServer? = null, - onDetailVisibilityChanged: (Boolean) -> Unit = {}, + onOpenDetail: (String) -> Unit = {}, ) { var results by remember(activeServer?.id) { mutableStateOf>(emptyList()) } var loading by remember(activeServer?.id) { mutableStateOf(false) } - var selectedItem by remember { mutableStateOf(null) } LaunchedEffect(query, activeServer?.id, activeServer?.apiKey) { val server = activeServer @@ -100,10 +99,6 @@ fun SearchScreen( results = EmbyRepository.searchItems(server, term) loading = false } - LaunchedEffect(selectedItem != null) { - onDetailVisibilityChanged(selectedItem != null) - } - Box(Modifier.fillMaxSize()) { Box( Modifier @@ -144,20 +139,12 @@ fun SearchScreen( SearchMediaCard( item = item, serverUrl = activeServer.fullUrl, - onClick = { selectedItem = item }, + onClick = { onOpenDetail(item.id) }, ) } } } } - - selectedItem?.let { item -> - MediaDetailScreen( - item = item, - server = activeServer, - onClose = { selectedItem = null }, - ) - } } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt new file mode 100644 index 0000000..46c4413 --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsChildScreens.kt @@ -0,0 +1,168 @@ +package com.example.myapplication.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.myapplication.R +import com.example.myapplication.data.IconLibraryRepository +import com.example.myapplication.ui.GlassTokens +import top.yukonga.miuix.kmp.basic.Text + +@Composable +private fun PageTopBar(title: String, onBack: () -> Unit) { + Row( + Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 18.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier + .size(36.dp) + .clip(CircleShape) + .background(GlassTokens.glassFill) + .border(0.5.dp, GlassTokens.glassBorder, CircleShape) + .clickable(onClick = onBack), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回", tint = GlassTokens.text, modifier = Modifier.size(20.dp)) + } + Spacer(Modifier.size(12.dp)) + Text(title, color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +fun CacheManagementScreen(onBack: () -> Unit) { + var cacheSize by remember { mutableLongStateOf(IconLibraryRepository.getIconCacheSize()) } + + Column(Modifier.fillMaxSize()) { + PageTopBar(title = "缓存管理", onBack = onBack) + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + CacheInfoCard( + title = "图标数据缓存", + value = formatCacheSize(cacheSize), + icon = Icons.Outlined.Info, + ) + } + item { + Box( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(GlassTokens.glassFill) + .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(18.dp)) + .clickable { + IconLibraryRepository.clearAllIconCache() + cacheSize = 0L + } + .padding(horizontal = 16.dp, vertical = 15.dp), + contentAlignment = Alignment.CenterStart, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Delete, contentDescription = null, tint = Color(0xFFE5484D), modifier = Modifier.size(21.dp)) + Spacer(Modifier.size(12.dp)) + Text("删除缓存", color = Color(0xFFE5484D), fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + } + } + } + } + } +} + +@Composable +private fun CacheInfoCard(title: String, value: String, icon: androidx.compose.ui.graphics.vector.ImageVector) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(GlassTokens.glassFill) + .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(18.dp)) + .padding(horizontal = 16.dp, vertical = 15.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(GlassTokens.accent.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center, + ) { + Icon(icon, contentDescription = null, tint = GlassTokens.accent, modifier = Modifier.size(21.dp)) + } + Spacer(Modifier.size(12.dp)) + Column(Modifier.weight(1f)) { + Text(title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(3.dp)) + Text("缓存数据总大小", color = GlassTokens.subtext, fontSize = 12.sp) + } + Text(value, color = GlassTokens.subtext, fontSize = 13.sp) + } +} + +@Composable +fun AboutScreen(onBack: () -> Unit) { + Column(Modifier.fillMaxSize()) { + PageTopBar(title = "关于", onBack = onBack) + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + Modifier.size(72.dp).clip(RoundedCornerShape(22.dp)).background(GlassTokens.accent.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Icon(Icons.Outlined.Info, contentDescription = null, tint = GlassTokens.accent, modifier = Modifier.size(36.dp)) + } + Spacer(Modifier.height(14.dp)) + Text("LumaPlayer", color = GlassTokens.text, fontSize = 21.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(6.dp)) + Text("版本 1.0", color = GlassTokens.subtext, fontSize = 13.sp) + Spacer(Modifier.height(22.dp)) + Box( + Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(GlassTokens.glassFill) + .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(18.dp)).padding(16.dp), + ) { + Text("一款简洁优雅的 Emby 媒体库管理工具", color = GlassTokens.text, fontSize = 14.sp) + } + } + } +} + +private fun formatCacheSize(bytes: Long): String = when { + bytes < 1024L -> "$bytes B" + bytes < 1024L * 1024L -> "%.1f KB".format(bytes / 1024f) + else -> "%.1f MB".format(bytes / (1024f * 1024f)) +} diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt index 637def1..255a656 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt @@ -95,12 +95,8 @@ fun SettingsTopBar(modifier: Modifier = Modifier) { @Composable fun SettingsScreen( servers: List = emptyList(), - showCacheDetail: Boolean = false, onOpenCacheDetail: () -> Unit = {}, - onBackFromCache: () -> Unit = {}, - showAboutDetail: Boolean = false, onOpenAbout: () -> Unit = {}, - onBackFromAbout: () -> Unit = {}, ) { var showLanguageDialog by remember { mutableStateOf(false) } var languageMenuMounted by remember { mutableStateOf(false) } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt index 8a93fe3..d3f747f 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt @@ -87,6 +87,7 @@ import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.colorControls import com.kyant.backdrop.effects.lens import com.kyant.backdrop.effects.vibrancy import com.kyant.backdrop.highlight.Highlight @@ -459,7 +460,14 @@ private fun LiquidGlassPanel( Modifier.drawBackdrop( backdrop = bd, shape = { RoundedRectangle(24.dp) }, - effects = { blur(24f.dp.toPx()); vibrancy() }, + effects = { + colorControls(brightness = 0f, saturation = 1.15f) + vibrancy() + blur(24f.dp.toPx()) + lens(24f.dp.toPx(), 42f.dp.toPx(), depthEffect = true) + }, + highlight = { Highlight.Plain }, + shadow = { Shadow(alpha = 0.22f) }, onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.5f)) }, ) } else { @@ -518,12 +526,27 @@ private data class SubtitleTrackInfo( @Composable private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit) { + val bd = LocalPlayerBackdrop.current + val shape = RoundedCornerShape(12.dp) + val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f) Row( Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (selected) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.05f)) + .clip(shape) + .then( + if (bd != null) { + Modifier.drawBackdrop( + backdrop = bd, + shape = { RoundedRectangle(12.dp) }, + effects = { vibrancy(); blur(8.dp.toPx()); lens(10.dp.toPx(), 16.dp.toPx()) }, + onDrawSurface = { drawRect(surface) }, + ) + } else { + Modifier.background(surface) + } + ) .clickable(onClick = onClick) + .border(0.5.dp, Color.White.copy(alpha = 0.18f), shape) .padding(14.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -534,12 +557,27 @@ private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit) @Composable private fun LiquidEpisodeItem(episode: EmbyItem, displayIndex: Int, selected: Boolean, onClick: () -> Unit) { + val bd = LocalPlayerBackdrop.current + val shape = RoundedCornerShape(12.dp) + val surface = if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f) Row( Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (selected) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.05f)) + .clip(shape) + .then( + if (bd != null) { + Modifier.drawBackdrop( + backdrop = bd, + shape = { RoundedRectangle(12.dp) }, + effects = { vibrancy(); blur(8.dp.toPx()); lens(10.dp.toPx(), 16.dp.toPx()) }, + onDrawSurface = { drawRect(surface) }, + ) + } else { + Modifier.background(surface) + } + ) .clickable(onClick = onClick) + .border(0.5.dp, Color.White.copy(alpha = 0.18f), shape) .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/app/src/main/kotlin/com/example/myapplication/screens/library/IconLibraryManagerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/library/IconLibraryManagerScreen.kt new file mode 100644 index 0000000..9f7ee8e --- /dev/null +++ b/app/src/main/kotlin/com/example/myapplication/screens/library/IconLibraryManagerScreen.kt @@ -0,0 +1,27 @@ +package com.example.myapplication.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.example.myapplication.data.IconItem +import com.example.myapplication.data.IconLibrary + +@Composable +fun IconLibraryManagerScreen( + libraries: List, + modifier: Modifier = Modifier, + onBack: () -> Unit = {}, + onAddIconLibrary: (String, String, List, Boolean) -> Unit = { _, _, _, _ -> }, + onUpdateIconLibrary: (IconLibrary) -> Unit = {}, + onDeleteIconLibrary: (String) -> Unit = {}, + onRefreshIconLibrary: (String) -> Unit = {}, +) { + IconLibraryManagerScreenContent( + libraries = libraries, + modifier = modifier, + onBack = onBack, + onAddIconLibrary = onAddIconLibrary, + onUpdateIconLibrary = onUpdateIconLibrary, + onDeleteIconLibrary = onDeleteIconLibrary, + onRefreshIconLibrary = onRefreshIconLibrary, + ) +} diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index 6e3b04d..c4f36e3 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -1,5 +1,7 @@ package com.example.myapplication.ui +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween @@ -11,11 +13,15 @@ import dev.chrisbanes.haze.hazeEffect import com.kyant.backdrop.Backdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.colorControls import com.kyant.backdrop.effects.lens import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.Shadow import com.kyant.shapes.RoundedRectangle import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement @@ -51,6 +57,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.drawWithCache @@ -62,6 +69,8 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.res.stringResource import com.example.myapplication.R as Res @@ -73,16 +82,18 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.LaunchedEffect import kotlinx.coroutines.delay +import kotlin.math.roundToInt +import androidx.compose.ui.zIndex import androidx.compose.material.icons.outlined.ChevronRight import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.Icon import top.yukonga.miuix.kmp.basic.Text import androidx.compose.material.icons.Icons @@ -497,23 +508,119 @@ fun GlassSearchField( } @Composable -fun GlassPopup( - expanded: Boolean, +fun LiquidGlassPopupOverlay( + anchorBounds: Rect, + rootOrigin: Offset, + backdrop: Backdrop?, + visible: Boolean, onDismissRequest: () -> Unit, + onClosed: () -> Unit = {}, modifier: Modifier = Modifier, + width: Dp = 184.dp, + height: Dp = 152.dp, content: @Composable ColumnScope.() -> Unit, ) { - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismissRequest, - modifier = modifier - .width(IntrinsicSize.Min) - .clip(RoundedCornerShape(16.dp)) - .background(GlassTokens.glassFillStrong) - .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(16.dp)) - .padding(vertical = 4.dp), - ) { - content() + val density = androidx.compose.ui.platform.LocalDensity.current + val progress = remember { Animatable(0f) } + val currentOnClosed by androidx.compose.runtime.rememberUpdatedState(onClosed) + + LaunchedEffect(visible) { + if (visible) { + progress.animateTo( + targetValue = 1f, + animationSpec = tween(180, easing = CubicBezierEasing(0.18f, 0.82f, 0.28f, 1f)), + ) + } else { + progress.animateTo( + targetValue = 0f, + animationSpec = tween(260, easing = CubicBezierEasing(0.35f, 0f, 0.72f, 0.18f)), + ) + currentOnClosed() + } + } + + val rawProgress = progress.value + if (!visible && rawProgress <= 0.001f) return + + val initialSize = 40.dp + val widthProgress = 1f - (1f - rawProgress) * (1f - rawProgress) * (1f - rawProgress) + val heightProgress = 1f - (1f - rawProgress) * (1f - rawProgress) + val menuWidth = initialSize + (width - initialSize) * widthProgress + val menuHeight = initialSize + (height - initialSize) * heightProgress + val contentProgress = ((rawProgress - 0.34f) / 0.66f).coerceIn(0f, 1f) + val cornerRadius = initialSize / 2f + (18.dp - initialSize / 2f) * rawProgress + val initialSizePx = with(density) { initialSize.toPx() } + val anchorCenterX = anchorBounds.center.x - rootOrigin.x + val anchorCenterY = anchorBounds.center.y - rootOrigin.y + val menuWidthPx = with(density) { menuWidth.toPx() } + val menuLeft = anchorCenterX + initialSizePx / 2f - menuWidthPx + val menuTop = anchorCenterY - initialSizePx / 2f + val menuRect = Rect( + menuLeft, + menuTop, + menuLeft + menuWidthPx, + menuTop + with(density) { menuHeight.toPx() }, + ) + val currentMenuRect by androidx.compose.runtime.rememberUpdatedState(menuRect) + val shape = RoundedCornerShape(cornerRadius) + val surface = if (GlassTokens.isDark) { + Color(0xFF14171D).copy(alpha = 0.42f) + } else { + Color.White.copy(alpha = 0.20f) + } + val border = if (GlassTokens.isDark) Color.White.copy(alpha = 0.24f) else Color.White.copy(alpha = 0.62f) + + Box(modifier.fillMaxSize().zIndex(20f)) { + Box( + Modifier + .fillMaxSize() + .pointerInput(visible) { + detectTapGestures { position -> + if (!currentMenuRect.contains(position)) onDismissRequest() + } + }, + ) + Box( + Modifier + .offset { IntOffset(menuLeft.roundToInt(), menuTop.roundToInt()) } + .width(menuWidth) + .height(menuHeight) + .clip(shape) + .then( + if (backdrop != null) { + Modifier.drawBackdrop( + backdrop = backdrop, + shape = { RoundedRectangle(cornerRadius) }, + effects = { + colorControls( + brightness = if (GlassTokens.isDark) 0f else 0.10f, + saturation = 1.25f, + ) + vibrancy() + blur(if (GlassTokens.isDark) 8.dp.toPx() else 16.dp.toPx()) + lens(18.dp.toPx(), 32.dp.toPx(), depthEffect = true) + }, + highlight = { Highlight.Plain }, + shadow = { Shadow(alpha = 0.18f) }, + onDrawSurface = { drawRect(surface) }, + ) + } else { + Modifier.background(surface) + } + ) + .border(0.7.dp, border, shape) + .zIndex(1f), + ) { + Column( + Modifier + .fillMaxSize() + .padding(vertical = 4.dp) + .alpha(contentProgress) + .offset(y = 6.dp * (1f - contentProgress)), + verticalArrangement = Arrangement.Center, + content = content, + ) + } } } @@ -610,23 +717,58 @@ fun GlassButton( @Composable fun GlassDialog( onDismissRequest: () -> Unit, + backdrop: Backdrop? = null, modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { Dialog(onDismissRequest = onDismissRequest) { val dialogCtx = LocalContext.current val localizedCtx = remember(AppLanguage.language) { AppLanguage.getLocalizedContext(dialogCtx) } - CompositionLocalProvider(LocalContext provides localizedCtx) { - Box( - modifier - .width(IntrinsicSize.Min) - .clip(RoundedCornerShape(24.dp)) - .background(GlassTokens.glassFillStrong) - .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(24.dp)) - .padding(20.dp), + val glassBackdrop = backdrop ?: LocalGlassBackdrop.current + val shape = RoundedCornerShape(24.dp) + CompositionLocalProvider( + LocalContext provides localizedCtx, + LocalGlassBackdrop provides glassBackdrop, ) { - content() - } + Box( + modifier + .width(IntrinsicSize.Min) + .clip(shape) + .then( + if (glassBackdrop != null) { + Modifier.drawBackdrop( + backdrop = glassBackdrop, + shape = { RoundedRectangle(24.dp) }, + effects = { + colorControls( + brightness = if (GlassTokens.isDark) 0f else 0.08f, + saturation = 1.18f, + ) + vibrancy() + blur(if (GlassTokens.isDark) 10.dp.toPx() else 18.dp.toPx()) + lens(24.dp.toPx(), 42.dp.toPx(), depthEffect = true) + }, + highlight = { Highlight.Plain }, + shadow = { Shadow(alpha = 0.20f) }, + onDrawSurface = { + drawRect( + if (GlassTokens.isDark) { + Color(0xFF171A20).copy(alpha = 0.42f) + } else { + Color.White.copy(alpha = 0.24f) + } + ) + }, + ) + } else { + Modifier.background(GlassTokens.glassFillStrong) + } + ) + .border(0.7.dp, GlassTokens.glassBorder, shape) + .padding(20.dp), + ) { + content() + } } } } @@ -644,12 +786,25 @@ fun GlassInputField( errorMessage: String? = null, focusRequester: FocusRequester? = null, ) { + val backdrop = LocalGlassBackdrop.current Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(label, fontSize = 13.sp, color = Color(0xFF6B7280), fontWeight = FontWeight.Medium) + val shape = RoundedCornerShape(12.dp) Box( - Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)) - .background(GlassTokens.glassFillSoft) - .border(1.dp, if (isError) GlassTokens.danger else GlassTokens.glassBorder, RoundedCornerShape(12.dp)) + Modifier.fillMaxWidth().clip(shape) + .then( + if (backdrop != null) { + Modifier.drawBackdrop( + backdrop = backdrop, + shape = { RoundedRectangle(12.dp) }, + effects = { vibrancy(); blur(8.dp.toPx()); lens(10.dp.toPx(), 18.dp.toPx()) }, + onDrawSurface = { drawRect(GlassTokens.glassFillSoft) }, + ) + } else { + Modifier.background(GlassTokens.glassFillSoft) + } + ) + .border(1.dp, if (isError) GlassTokens.danger else GlassTokens.glassBorder, shape) .padding(horizontal = 14.dp, vertical = 12.dp), ) { BasicTextField( diff --git a/memory/2026-08-27.md b/memory/2026-08-27.md index d72843d..38bf299 100644 --- a/memory/2026-08-27.md +++ b/memory/2026-08-27.md @@ -10,3 +10,45 @@ 文件变更: screens/SettingsScreen.kt。 教训: Overlay 即使不参与测量,若最终几何直接贴在整行顶部也会产生“Row 横向展开”的视觉错觉;应锚定入口图标并固定展开方向与尺寸。 标签: #lumaplayer #language-menu #overlay #morph #backdrop + +[PROJECT:lumaplayer] 统一菜单型弹出层 +结论: 资源库操作菜单改为独立 Root Overlay,播放器字幕/剧集菜单项复用 AndroidLiquidGlass 的 backdrop、blur、lens、vibrancy;新增菜单不参与卡片或列表布局。 +文件变更: MainApp.kt、screens/LibraryScreen.kt、screens/VideoPlayerScreen.kt、ui/LiquidGlass.kt。 +教训: 资源库 Overlay 必须使用页面内部专属 source,不能直接消费包住整个页面的外层 source,否则会触发 RenderThread SIGSEGV。 +标签: #lumaplayer #popup #overlay #backdrop #android-liquid-glass + +[PROJECT:lumaplayer] 添加服务器弹窗液态玻璃化 +结论: AddServerDialog 显式复用资源库专属 Backdrop,GlassDialog 与 GlassInputField 增加真实背板模糊、vibrancy、lens、高光和阴影,字段、按钮及提交逻辑保持不变。 +文件变更: screens/LibraryScreen.kt、ui/LiquidGlass.kt。 +教训: 表单弹窗也必须让 glass consumer 与底层 Backdrop source 分离,避免弹窗自身被捕获造成循环渲染。 +标签: #lumaplayer #add-server #dialog #backdrop + +[PROJECT:lumaplayer] 添加服务器改为独立页面 +结论: 点击资源库添加入口直接进入普通全屏表单页,删除添加流程的液态玻璃弹窗与字段样式,原认证、校验、保存和取消逻辑保持不变。 +文件变更: MainApp.kt、screens/LibraryScreen.kt。 +教训: 添加页面与编辑弹窗共用表单逻辑时,通过展示模式切换输入控件,避免复制业务代码。 +标签: #lumaplayer #add-server #page #plain-form + +[PROJECT:lumaplayer] 添加服务器协议开关 +结论: 添加/编辑服务器表单增加 HTTPS 开关,协议写入 Room 并由 EmbyServer.fullUrl 统一决定请求使用 HTTP 或 HTTPS。 +文件变更: screens/LibraryScreen.kt、data/EmbyServer.kt、data/ServerRepository.kt、data/ServerEntity.java、data/AppDatabase.java。 +教训: Room Java 实体新增字段时,迁移列的可空性必须匹配生成的 Schema 校验。 +标签: #lumaplayer #http #https #room-migration + +[PROJECT:lumaplayer] 页面导航架构重构 +结论: 使用 Navigation Compose 建立根 Tab、详情、播放器、图标库管理、缓存、关于和添加服务器路由;页面由 NavController/BackStack 决定,统一 Push/Pop/Tab 横向转场并移除旧页面 Boolean。 +文件变更: app/build.gradle.kts、MainApp.kt、navigation/AppRoute.kt、navigation/AppNavHost.kt、navigation/NavigationTransitions.kt、screens/SearchScreen.kt、screens/MediaDetailScreen.kt、screens/LibraryScreen.kt、screens/SettingsScreen.kt、screens/SettingsChildScreens.kt、screens/library/IconLibraryManagerScreen.kt。 +教训: 根 Tab 使用 popUpTo + launchSingleTop + saveState/restoreState,详情只在路由传 serverId/itemId;Backdrop 外层来源与页面玻璃消费层保持分离。 +标签: #lumaplayer #navigation #backstack #push-pop #compose + +[PROJECT:lumaplayer] 修复新页面自动回主页 +结论: NavController 改为 MainApp 稳定生命周期创建,并为页面导航增加当前 destination 去重,避免新页面打开后因控制器重建回到 Home。 +文件变更: MainApp.kt、navigation/AppNavHost.kt。 +教训: NavController 放在启动条件分支内部会在页面/配置重建时丢失 BackStack;必须在稳定的 Compose remember 范围内创建。 +标签: #lumaplayer #navigation #backstack #bugfix + +[PROJECT:lumaplayer] 修复底栏误触发 Home +结论: 子页面过渡期间不再将底栏选中态重置为 0,且隐藏动画中的底栏回调被屏蔽,避免液态 Tab 组件误导航到 Home。 +文件变更: MainApp.kt。 +教训: 底栏隐藏动画仍处于组合期间,必须保持最后根路由选中态并禁止回调,否则内部指示器同步会被当作用户点击。 +标签: #lumaplayer #bottom-bar #navigation #bugfix diff --git a/memory/MEMORY.md b/memory/MEMORY.md index c6d0aeb..be0b72e 100644 --- a/memory/MEMORY.md +++ b/memory/MEMORY.md @@ -2,7 +2,7 @@ - 项目:lumaplayer,Android Kotlin/Compose 媒体库客户端。 - 当前 Git 分支:`master`。 -- 最近同步:远端 `fc35ae9`,本地合并提交 `944291d`。 -- 当前实现:语言菜单与底栏使用 AndroidLiquidGlass/Backdrop 2.0.1,交互验证通过。 +- 最近同步:远端与本地均为 `5d41a0b`。 +- 当前实现:语言菜单、资源库操作菜单、播放器菜单与底栏使用 AndroidLiquidGlass/Backdrop 2.0.1;添加服务器为独立普通表单页面并支持 HTTP/HTTPS 配置持久化;页面导航已迁移到 Navigation Compose。 - 构建验证:`assembleDebug` 成功。 - 详细状态见 [projects.md](projects.md),踩坑见 [lessons.md](lessons.md)。 diff --git a/memory/lessons.md b/memory/lessons.md index 622d24a..72836ea 100644 --- a/memory/lessons.md +++ b/memory/lessons.md @@ -7,3 +7,5 @@ - 中:远端提交与本地大范围未提交 UI 改动并行时,先暂存本地改动、合并远端非冲突文件,再恢复本地改动并手动吸收远端业务行为,避免整文件覆盖。 - 高:Backdrop 的来源层与消费玻璃必须是同级兄弟层;菜单外部手势层也必须位于菜单内容下方,否则打开手势会误触发关闭。 - 中:Backdrop 的玻璃效果是否明显取决于来源内容;纯白卡片上无法显示明显折射,需依靠仓库的高光、阴影和 lens 边缘表现,不能用普通白色背景冒充。 +- 高:Root Overlay 若直接消费外层页面 `layerBackdrop`,外层 source 会把 Overlay 再捕获形成 RenderNode 循环;必须为局部页面建立只包裹底层内容的 source,并让 Overlay 作为兄弟 consumer。 +- 高:Room 实体由 Java 可空字段映射时,迁移列定义必须与 Room 的 `notNull` 预期一致;新增 `protocol` 使用可空列并在模型读取时回退 HTTPS,否则旧数据库启动会因迁移校验失败崩溃。 diff --git a/memory/projects.md b/memory/projects.md index cb631d6..1107e9f 100644 --- a/memory/projects.md +++ b/memory/projects.md @@ -14,3 +14,10 @@ - 2026-08-27:语言菜单改为真实锚点的 Backdrop 液态玻璃 Morph Overlay;底栏主体和搜索圆钮复用 AndroidLiquidGlass-kmp 的 `drawBackdrop`、`vibrancy`、`blur`、`lens`、高光和阴影实现;展开 180ms、关闭 300ms,外部点击和语言选择验证通过。 - 当前依赖:`io.github.kyant0:backdrop:2.0.1`、`io.github.kyant0:shapes:1.2.1`;未纳入本地参考素材和压缩包。 - 2026-08-27:重做语言菜单 Overlay 几何,改用右侧 Chevron 的 `onGloballyPositioned` 作为锚点,44dp 圆形向左下 Morph 为 184×112dp 独立玻璃容器;LanguageRow/PlaybackRow bounds 保持不变。 +- 2026-08-27:资源库操作菜单移除 Material `DropdownMenu`,改为资源库内容专属 Backdrop source 下的 Root Overlay;真实记录更多图标坐标,40dp 玻璃圆向左下 Morph 为 184×152dp 菜单;播放器字幕/剧集菜单选项补齐 `drawBackdrop` 玻璃效果;Debug 构建和模拟器交互验证通过。 +- 2026-08-27:添加服务器弹窗显式消费资源库专属 Backdrop,增强 GlassDialog 与 GlassInputField 的真实玻璃效果;表单布局、字段校验、保存逻辑未改;Debug 构建和模拟器无崩溃验证通过。 +- 2026-08-27:添加服务器改为独立页面,移除该流程的 GlassDialog、Backdrop 字段和玻璃按钮;保留原表单校验、认证保存和返回逻辑;模拟器页面验证通过。 +- 2026-08-27:添加服务器页面增加 HTTPS 开关,协议写入服务器模型与 Room 6 版本迁移;关闭请求 HTTP,开启请求 HTTPS,旧数据库启动迁移验证通过。 +- 2026-08-27:建立 Navigation Compose 页面架构;Home/Records/Library/Settings/Search 为根路由,详情、播放器、图标库管理、缓存、关于、添加服务器为独立 destination;统一 Push/Pop 与 Tab 转场,移除页面导航 Boolean 和嵌套页面;`assembleDebug` 成功。 +- 2026-08-27:修复新 destination 打开后回到 Home 的问题;NavController 提升到 MainApp 稳定生命周期,静态页面和详情/播放器增加当前 destination 去重;`assembleDebug` 成功。 +- 2026-08-27:定位并修复仍会回 Home 的实际触发源;子页面打开时底栏退出动画的默认 Tab=0 被液态 Tab 组件误当成点击,现保留上个根 Tab 且子页面期间屏蔽底栏回调;`assembleDebug` 成功。