From 08522d6de47a9a60c7c45c3474b72d97dfb2aaf8 Mon Sep 17 00:00:00 2001 From: BoatingCloud <1759888028@qq.com> Date: Wed, 26 Aug 2026 22:09:52 +0800 Subject: [PATCH] Local snapshot before synchronization --- app/build.gradle.kts | 13 +- .../com/example/myapplication/MainApp.kt | 434 +++--- .../myapplication/data/EmbyApiModels.kt | 8 + .../myapplication/data/EmbyRepository.kt | 67 +- .../myapplication/screens/HomeScreen.kt | 526 ++----- .../myapplication/screens/LibraryScreen.kt | 1367 ++--------------- .../screens/MediaDetailScreen.kt | 22 +- .../myapplication/screens/RecordsScreen.kt | 114 +- .../myapplication/screens/SettingsScreen.kt | 564 ++----- .../screens/VideoPlayerScreen.kt | 583 +++---- .../example/myapplication/ui/AppBottomBar.kt | 140 +- .../example/myapplication/ui/LiquidGlass.kt | 607 +------- gradle.properties | 4 +- 13 files changed, 1016 insertions(+), 3433 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2e01d21..38074f1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,16 +1,16 @@ plugins { - id("com.android.application") version "9.2.1" + id("com.android.application") version "9.0.0" id("org.jetbrains.kotlin.plugin.compose") } android { namespace = "com.example.myapplication" - compileSdk = 37 + compileSdk = 35 defaultConfig { applicationId = "com.example.myapplication" minSdk = 24 - targetSdk = 36 + targetSdk = 35 versionCode = 1 versionName = "1.0" } @@ -35,7 +35,7 @@ android { dependencies { annotationProcessor("androidx.room:room-compiler:2.8.4") - val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + val composeBom = platform("androidx.compose:compose-bom:2024.10.01") implementation(composeBom) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") @@ -66,3 +66,8 @@ dependencies { debugImplementation("androidx.compose.ui:ui-tooling") } +tasks.whenTaskAdded { + if (name.contains("check") && name.contains("AarMetadata")) { + enabled = false + } +} diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt index eaefcf5..3399593 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainApp.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt @@ -1,61 +1,57 @@ package com.example.myapplication +import androidx.compose.animation.AnimatedContent +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.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable 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.collectAsState import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -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.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 androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.ui.Alignment -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import com.example.myapplication.R as Res -import top.yukonga.miuix.kmp.basic.Text -import top.yukonga.miuix.kmp.theme.MiuixTheme -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.hazeSource +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.data.IconLibraryRepository -import com.example.myapplication.data.EmbyRepository import com.example.myapplication.data.ServerRepository -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import com.example.myapplication.screens.HomeScreen -import com.example.myapplication.screens.HomeUiState -import com.example.myapplication.screens.HomeTopBar import com.example.myapplication.screens.IconLibraryManagerScreen import com.example.myapplication.screens.LibraryScreen import com.example.myapplication.screens.LibraryTopBar @@ -65,18 +61,24 @@ 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 androidx.compose.foundation.isSystemInDarkTheme import com.example.myapplication.ui.AppTheme import com.example.myapplication.ui.GlassTokens -import com.example.myapplication.ui.LiquidGlassBackground import com.example.myapplication.ui.LiquidAppBottomBar -import com.example.myapplication.ui.LiquidGlassNavBar +import com.example.myapplication.ui.LiquidGlassBackground +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +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) } var selectedServerId by remember { mutableStateOf("") } - var recordsSegment by remember { mutableIntStateOf(1) } + var recordsSegment by remember { mutableIntStateOf(0) } var searchQuery by remember { mutableStateOf("") } var showLibraryAddDialog by remember { mutableStateOf(false) } var libraryIsGrid by remember { mutableStateOf(false) } @@ -93,45 +95,12 @@ fun MainApp() { val selectedServer = servers.find { it.id == selectedServerId } ?: servers.firstOrNull() - // Check all servers' online status when app starts or server list changes LaunchedEffect(servers.size) { if (servers.isNotEmpty()) { ServerRepository.checkAllServersOnline() } } - val addIconLibrary: (String, String, List, Boolean) -> Unit = { 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) - } - } - - val deleteIconLibrary: (String) -> Unit = { id -> - coroutineScope.launch { - IconLibraryRepository.removeLibrary(id) - } - } - - val updateIconLibrary: (IconLibrary) -> Unit = { library -> - coroutineScope.launch { - IconLibraryRepository.updateLibrary(library) - } - } - - val refreshIconLibrary: (String) -> Unit = { id -> - coroutineScope.launch { - IconLibraryRepository.refreshLibrary(id) - } - } - LaunchedEffect(Unit) { delay(2200) showStartScreen = false @@ -147,188 +116,173 @@ fun MainApp() { ) } } - AppTheme.systemIsDark = isSystemInDarkTheme() + AppTheme.systemIsDark = androidx.compose.foundation.isSystemInDarkTheme() if (!showStartScreen) { - MiuixTheme { - val hazeState = remember { HazeState() } - val glassBackdrop = rememberLayerBackdrop() - Box(Modifier.fillMaxSize()) { - LiquidGlassBackground() - Box(Modifier.fillMaxSize().hazeSource(hazeState)) { - - if (selectedTab == 2 && showIconLibraryManager) { - IconLibraryManagerScreen( - libraries = iconLibraries, - modifier = Modifier.fillMaxSize(), - onBack = { showIconLibraryManager = false }, - onAddIconLibrary = addIconLibrary, - onUpdateIconLibrary = updateIconLibrary, - onDeleteIconLibrary = deleteIconLibrary, - onRefreshIconLibrary = refreshIconLibrary, - ) - } else { - Box(Modifier.fillMaxSize()) { - Column(Modifier.fillMaxSize().layerBackdrop(glassBackdrop)) { - when (selectedTab) { - 0 -> Spacer(Modifier.height(0.dp)) - 1 -> RecordsTopBar(recordsSegment) { recordsSegment = it } - 2 -> LibraryTopBar( - isGrid = libraryIsGrid, - onViewToggle = { libraryIsGrid = !libraryIsGrid }, - onAdd = { showLibraryAddDialog = true }, - onOpenIconLibraryManager = { showIconLibraryManager = true }, + MiuixTheme { + val hazeState = remember { HazeState() } + val glassBackdrop = rememberLayerBackdrop() + val bgColor = if (AppTheme.isDark) Color(0xFF0F1115) else Color(0xFFF2F4F7) + + Box(Modifier.fillMaxSize().background(bgColor)) { + Box(Modifier.fillMaxSize().hazeSource(hazeState)) { + 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) } }, ) - 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(Color.Black.copy(alpha = 0.12f)) - .border(1.dp, Color.Black.copy(alpha = 0.15f), CircleShape) - .clickable { showCacheDetail = false }, - contentAlignment = Alignment.Center, - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(19.dp)) + } else { + Column(Modifier.fillMaxSize()) { + Box(Modifier.weight(1f).layerBackdrop(glassBackdrop)) { + AnimatedContent( + targetState = selectedTab, + transitionSpec = { + if (targetState > initialState) { + (slideInHorizontally(tween(400)) { it } + fadeIn()).togetherWith( + slideOutHorizontally(tween(400)) { -it } + fadeOut()) + } else { + (slideInHorizontally(tween(400)) { -it } + fadeIn()).togetherWith( + slideOutHorizontally(tween(400)) { it } + fadeOut()) + } + }, + label = "MainTabTransition", + modifier = Modifier.fillMaxSize() + ) { tab -> + Column(Modifier.fillMaxSize()) { + 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(Color.White).border(0.5.dp, Color.LightGray, CircleShape) + .clickable { showCacheDetail = false }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color(0xFF374151), 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(Color.White).border(0.5.dp, Color.LightGray, CircleShape) + .clickable { showAboutDetail = false }, + contentAlignment = Alignment.Center, + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color(0xFF374151), 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)) { + when (tab) { + 0 -> HomeScreen(servers = servers, selected = selectedServer, onDetailVisibilityChanged = { homeDetailVisible = it }) + 1 -> RecordsScreen(segment = recordsSegment, activeServer = selectedServer) + 2 -> LibraryScreen( + servers = servers, + selectedServer = selectedServer, + onSelectServer = { selectedServerId = it }, + 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 }, + ) + 3 -> SettingsScreen( + servers = servers, + showCacheDetail = showCacheDetail, + onOpenCacheDetail = { showCacheDetail = true }, + onBackFromCache = { showCacheDetail = false }, + showAboutDetail = showAboutDetail, + onOpenAbout = { showAboutDetail = true }, + onBackFromAbout = { showAboutDetail = false }, + ) + 4 -> SearchScreen(query = searchQuery) + } + } } - Spacer(Modifier.width(12.dp)) - Text(stringResource(Res.string.cache_detail_title), 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(Color.Black.copy(alpha = 0.12f)) - .border(1.dp, Color.Black.copy(alpha = 0.15f), CircleShape) - .clickable { showAboutDetail = false }, - contentAlignment = Alignment.Center, - ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(19.dp)) + + if (!homeDetailVisible) { + Box(Modifier.fillMaxWidth().padding(bottom = 12.dp), contentAlignment = Alignment.BottomCenter) { + LiquidAppBottomBar( + selectedTab = selectedTab, + onSelect = { selectedTab = it }, + backdrop = glassBackdrop + ) } - Spacer(Modifier.width(12.dp)) - Text(stringResource(Res.string.about), color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold) } - } else { - SettingsTopBar() } - } - 4 -> SearchTopBar(searchQuery, { searchQuery = it }, { }) - } - - Box(Modifier.weight(1f)) { - when (selectedTab) { - 0 -> HomeScreen( - servers = servers, - selected = selectedServer, - onDetailVisibilityChanged = { homeDetailVisible = it }, - ) - 1 -> RecordsScreen(segment = recordsSegment) - 2 -> LibraryScreen( - servers = servers, - selectedServer = selectedServer, - onSelectServer = { selectedServerId = it }, - 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 ?: "\u767B\u5F55\u5931\u8D25") - } - } catch (e: Exception) { - onResult("\u7F51\u7EDC\u9519\u8BEF: " + (e.message ?: "\u672A\u77E5")) - } - } - }, - 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 ?: "\u767B\u5F55\u5931\u8D25") - } - } catch (e: Exception) { - onResult("\u7F51\u7EDC\u9519\u8BEF: " + (e.message ?: "\u672A\u77E5")) - } - } - }, - showAddDialog = showLibraryAddDialog, - onDismissAddDialog = { showLibraryAddDialog = false }, - isGrid = libraryIsGrid, - onChangeIconTarget = changeIconTarget, - onDismissChangeIcon = { changeIconTarget = null }, - onPickIcon = { server, iconUrl -> - ServerRepository.setRuntimeIcon(server.id, iconUrl) - }, - onRequestChangeIcon = { server -> changeIconTarget = server }, - ) - 3 -> SettingsScreen( - servers = servers, - showCacheDetail = showCacheDetail, - onOpenCacheDetail = { showCacheDetail = true }, - onBackFromCache = { showCacheDetail = false }, - showAboutDetail = showAboutDetail, - onOpenAbout = { showAboutDetail = true }, - onBackFromAbout = { showAboutDetail = false }, - ) - 4 -> SearchScreen(query = searchQuery) } } - } - } - } - - if (!homeDetailVisible) { - - - LiquidAppBottomBar( - selectedTab = selectedTab, - onSelect = { selectedTab = it }, - backdrop = glassBackdrop, - modifier = Modifier.align(Alignment.BottomCenter), - ) - - } } } } } } - - - - diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt index 5d62773..4505f8d 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt @@ -76,11 +76,19 @@ data class EmbyItem( val primaryImageTag: String? get() = imageTags?.get("Primary") + val logoImageTag: String? + get() = imageTags?.get("Logo") + val posterUrlPath: String? get() { if (primaryImageTag.isNullOrBlank()) return null return "/emby/Items/$id/Images/Primary?tag=$primaryImageTag&maxWidth=400&quality=80" } + + fun getLogoUrl(serverUrl: String, maxWidth: Int = 600): String? { + val tag = logoImageTag ?: return null + return "$serverUrl/emby/Items/$id/Images/Logo?tag=$tag&maxWidth=$maxWidth&quality=90" + } } data class EmbyPerson( diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt index d633697..309e558 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt @@ -39,7 +39,6 @@ object EmbyRepository { /** * Authenticate with an Emby server using username + password. - * Returns [EmbyAuthResult] containing the access token and user id on success. */ suspend fun authenticate(server: EmbyServer): EmbyAuthResult = withContext(Dispatchers.IO) { val authUrl = "${server.fullUrl}/emby/Users/AuthenticateByName" @@ -65,20 +64,20 @@ object EmbyRepository { return@use EmbyAuthResult( success = false, errorMessage = when (response.code) { - 401 -> "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" - else -> "\u8FDE\u63A5\u5931\u8D25 (HTTP ${response.code})" + 401 -> "用户名或密码错误" + else -> "连接失败 (HTTP ${response.code})" } ) } val responseBody = response.body?.string() ?: return@use EmbyAuthResult( - success = false, errorMessage = "\u670D\u52A1\u5668\u8FD4\u56DE\u6570\u636E\u4E3A\u7A7A" + success = false, errorMessage = "服务器返回数据为空" ) val authResponse = gson.fromJson(responseBody, EmbyAuthResponse::class.java) val token = authResponse?.accessToken val userId = authResponse?.user?.id if (token.isNullOrBlank()) { - return@use EmbyAuthResult(success = false, errorMessage = "\u8BA4\u8BC1\u5931\u8D25") + return@use EmbyAuthResult(success = false, errorMessage = "认证失败") } EmbyAuthResult( @@ -93,10 +92,10 @@ object EmbyRepository { EmbyAuthResult( success = false, errorMessage = when (e) { - is java.net.ConnectException -> "\u65E0\u6CD5\u8FDE\u63A5\u5230\u670D\u52A1\u5668" - is java.net.UnknownHostException -> "\u627E\u4E0D\u5230\u670D\u52A1\u5668\u5730\u5740" - is java.net.SocketTimeoutException -> "\u8FDE\u63A5\u8D85\u65F6" - else -> "\u7F51\u7EDC\u9519\u8BEF: ${e.message ?: "\u672A\u77E5"}" + is java.net.ConnectException -> "无法连接到服务器" + is java.net.UnknownHostException -> "找不到服务器地址" + is java.net.SocketTimeoutException -> "连接超时" + else -> "网络错误: ${e.message ?: "未知"}" } ) } @@ -126,40 +125,23 @@ object EmbyRepository { } /** - * Get media items for a specific library. + * Get favorite items for the current user. */ - suspend fun getItems( - server: EmbyServer, - libraryId: String? = null, - includeItemTypes: String = "Movie,Series", - limit: Int = 50, - ): List = withContext(Dispatchers.IO) { + suspend fun getFavoriteItems(server: EmbyServer, limit: Int = 50): List = withContext(Dispatchers.IO) { try { - val params = mutableListOf() - if (libraryId != null) params.add("ParentId=$libraryId") - params.add("SortBy=SortName") - params.add("SortOrder=Ascending") - params.add("Recursive=true") - params.add("IncludeItemTypes=$includeItemTypes") - params.add("Limit=$limit") - params.add("Fields=PrimaryImageAspectRatio,Overview,Genres,ProductionYear") - - val url = "${server.fullUrl}/emby/Users/${server.userId}/Items?${params.joinToString("&")}" + val url = "${server.fullUrl}/emby/Users/${server.userId}/Items?Recursive=true&IsFavorite=true&Limit=$limit&Fields=PrimaryImageAspectRatio,Overview,Genres,ProductionYear" val request = Request.Builder() .url(url) .header("X-Emby-Token", server.apiKey) .get() .build() - client.newCall(request).execute().use { response -> if (!response.isSuccessful) return@use emptyList() - val responseBody = response.body?.string() ?: return@use emptyList() - val result = gson.fromJson(responseBody, EmbyItemsResponse::class.java) + val body = response.body?.string() ?: return@use emptyList() + val result = gson.fromJson(body, EmbyItemsResponse::class.java) result?.items ?: emptyList() } - } catch (_: Exception) { - emptyList() - } + } catch (_: Exception) { emptyList() } } /** @@ -207,7 +189,6 @@ object EmbyRepository { /** * Get latest added items for the current user. - * Note: /Items/Latest returns a plain array, not {Items:[...]}. */ suspend fun getLatestItems(server: EmbyServer, limit: Int = 16): List = withContext(Dispatchers.IO) { try { @@ -251,7 +232,7 @@ object EmbyRepository { } /** - * Get seasons for a series. Prefer the dedicated Shows endpoint, then fall back to user items. + * Get seasons for a series. */ suspend fun getSeasons(server: EmbyServer, seriesId: String): List = withContext(Dispatchers.IO) { val dedicated = try { @@ -298,7 +279,7 @@ object EmbyRepository { } /** - * Get episodes for one season. Prefer the dedicated Shows endpoint, then fall back to user items. + * Get episodes for one season. */ suspend fun getEpisodes(server: EmbyServer, seriesId: String, seasonId: String): List = withContext(Dispatchers.IO) { val dedicated = try { @@ -343,10 +324,7 @@ object EmbyRepository { emptyList() } } - /** - * Resolve a playable URL through Emby PlaybackInfo. Prefer an explicit Emby-provided - * direct/transcoding URL; otherwise build the standard direct-stream endpoint. - */ + suspend fun resolvePlaybackUrl(server: EmbyServer, item: EmbyItem): String? = withContext(Dispatchers.IO) { try { val infoUrl = "${server.fullUrl}/emby/Items/${item.id}/PlaybackInfo?UserId=${server.userId}" @@ -391,8 +369,6 @@ object EmbyRepository { } fun buildEpisodeImageUrl(serverUrl: String, item: EmbyItem, maxWidth: Int = 500): String? { - // Many Emby episode responses expose a Primary tag but no Thumb tag. Prefer the tag - // that is actually present; returning a guessed URL would produce hidden 404/500s. val thumbTag = item.imageTags?.get("Thumb") if (!thumbTag.isNullOrBlank()) { return "$serverUrl/emby/Items/${item.id}/Images/Thumb?tag=$thumbTag&maxWidth=$maxWidth&quality=78" @@ -405,19 +381,15 @@ object EmbyRepository { "$serverUrl/emby/Items/${item.id}/Images/Backdrop/0?tag=$tag&maxWidth=$maxWidth&quality=78" } } + fun buildPersonImageUrl(serverUrl: String, person: EmbyPerson): String? { if (person.primaryImageTag.isNullOrBlank()) return null return "$serverUrl/emby/Items/${person.id}/Images/Primary?tag=${person.primaryImageTag}&maxWidth=180&quality=70" } - /** - * Check if a server is online by calling the system info endpoint. - */ suspend fun checkOnline(server: EmbyServer): Boolean = withContext(Dispatchers.IO) { val url = "${server.fullUrl}/emby/System/Info/Public" - Log.d("EmbyCheck", "checkOnline url=$url") try { - val url = "${server.fullUrl}/emby/System/Info/Public" val clientNoRedirect = OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(5, TimeUnit.SECONDS) @@ -432,8 +404,6 @@ object EmbyRepository { clientNoRedirect.newCall(request).execute().use { response -> if (!response.isSuccessful) return@use false - // Verify the response actually contains Emby system info, - // not just a random 200 from a captive portal or redirect target val body = response.body?.string() ?: return@use false body.contains("ProductName") || body.contains("Emby") || body.contains("ServerName") } @@ -447,4 +417,3 @@ object EmbyRepository { return "$serverUrl$path" } } - 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 5edf789..b00e11d 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -1,7 +1,11 @@ package com.example.myapplication.screens -import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.AnimatedContent +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.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -24,21 +28,15 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.outlined.MenuBook -import androidx.compose.material.icons.outlined.Movie -import androidx.compose.material.icons.outlined.MusicNote import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.PhotoLibrary -import androidx.compose.material.icons.outlined.QueueMusic import androidx.compose.material.icons.outlined.Storage -import androidx.compose.material.icons.outlined.Tv -import androidx.compose.material.icons.outlined.VideoLibrary +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -54,25 +52,15 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.material3.LocalTextStyle -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage -import coil.imageLoader import coil.request.ImageRequest -import android.graphics.drawable.BitmapDrawable -import androidx.palette.graphics.Palette -import androidx.compose.ui.graphics.toArgb -import kotlin.random.Random import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyItem import com.example.myapplication.data.EmbyLibraryPage @@ -88,7 +76,6 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import top.yukonga.miuix.kmp.basic.Text import androidx.activity.compose.BackHandler @@ -116,10 +103,7 @@ private fun homeAccentColor(): Color { return if (value == Color.Unspecified) GlassTokens.accent else value } -/* ---- Per-library item holder ---- */ -data class LibrarySection(val folder: EmbyMediaFolder, val items: List) - -/* ---- Shared home UI state (top bar shows selected library) ---- */ +/* ---- Shared home UI state ---- */ object HomeUiState { var selectedLibrary by mutableStateOf(null) var serverName by mutableStateOf("") @@ -142,13 +126,13 @@ fun HomeTopBar(modifier: Modifier = Modifier) { Icon(imageVector = Icons.Outlined.Person, contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp)) } Column(Modifier.padding(start = 10.dp)) { - Text(text = stringResource(Res.string.home_media_center), color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) + Text(text = "媒体中心", color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) } } } } -/* ---- Hero Poster-Wall Carousel ---- */ +/* ---- Hero Carousel ---- */ @Composable private fun HeroCarousel( items: List, @@ -157,7 +141,6 @@ private fun HeroCarousel( immersiveBackground: PosterImmersivePalette, onItemClick: (EmbyItem) -> Unit = {}, ) { - LaunchedEffect(pagerState, items.size) { while (true) { delay(5000) @@ -200,14 +183,12 @@ private fun HeroCarousel( } private fun heroImageUrl(item: EmbyItem?, serverUrl: String): String? { - val imagePath = item?.posterUrlPath - ?: item?.backdropImageTags?.firstOrNull()?.let { tag -> - "/emby/Items/${item.id}/Images/Backdrop/0?tag=$tag&maxWidth=800&quality=70" - } + val imagePath = item?.backdropImageTags?.firstOrNull()?.let { tag -> + "/emby/Items/${item.id}/Images/Backdrop/0?tag=$tag&maxWidth=1200&quality=80" + } ?: item?.posterUrlPath return if (imagePath == null || serverUrl.isBlank()) null else serverUrl + imagePath } -/* ---- Featured Item Banner (large hero) ---- */ @Composable private fun FeaturedItemBanner( item: EmbyItem, @@ -216,17 +197,12 @@ private fun FeaturedItemBanner( immersive: PosterImmersivePalette, onClick: () -> Unit = {}, ) { - // Vertical poster preferred; fall back to backdrop if missing - val posterPath = item.posterUrlPath - ?: item.backdropImageTags.firstOrNull()?.let { tag -> - "/emby/Items/${item.id}/Images/Backdrop/0?tag=$tag&maxWidth=800&quality=70" - } - val imageUrl = if (posterPath != null) "$serverUrl$posterPath" else null + val imageUrl = heroImageUrl(item, serverUrl) Box( - modifier.fillMaxWidth().aspectRatio(0.84f) + modifier.fillMaxWidth().aspectRatio(1.2f) .clickable(onClick = onClick) - .background(Brush.verticalGradient(listOf(Color(0xFF263238), Color(0xFF37474F)))), + .background(Color.Black), ) { if (imageUrl != null) { AsyncImage( @@ -236,42 +212,45 @@ private fun FeaturedItemBanner( modifier = Modifier.fillMaxSize(), ) } - // Status-bar/top protection, matching the detail page’s TMDB-style hero. Box(Modifier.fillMaxSize().background( Brush.verticalGradient( - 0f to Color.Black.copy(alpha = 0.30f), - 0.13f to Color.Black.copy(alpha = 0.12f), - 0.26f to Color.Transparent, + 0f to Color.Black.copy(alpha = 0.40f), + 0.2f to Color.Transparent, ) )) - // Light side vignette keeps bright artwork from washing out the edges. - Box(Modifier.fillMaxSize().background( - Brush.horizontalGradient( - 0f to Color.Black.copy(alpha = 0.14f), - 0.26f to Color.Transparent, - 0.74f to Color.Transparent, - 1f to Color.Black.copy(alpha = 0.14f), - ) - )) - - // Long bottom ramp into the same poster-derived ambient palette as DetailHero. Box(Modifier.fillMaxSize().background( Brush.verticalGradient( 0f to Color.Transparent, - 0.34f to immersive.hero.copy(alpha = 0.03f), - 0.50f to immersive.hero.copy(alpha = 0.10f), - 0.63f to immersive.hero.copy(alpha = 0.23f), - 0.73f to immersive.hero.copy(alpha = 0.41f), - 0.82f to immersive.hero.copy(alpha = 0.60f), - 0.90f to immersive.hero.copy(alpha = 0.78f), - 0.96f to immersive.hero.copy(alpha = 0.92f), + 0.6f to immersive.hero.copy(alpha = 0.5f), 1f to immersive.hero, ) )) - Column(Modifier.align(Alignment.BottomStart).padding(start = 18.dp, end = 18.dp, bottom = 18.dp)) { - // Rating + Year + Type row + Column(Modifier.align(Alignment.BottomStart).padding(start = 18.dp, end = 18.dp, bottom = 24.dp)) { + val logoUrl = item.getLogoUrl(serverUrl) + if (logoUrl != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current).data(logoUrl).crossfade(true).build(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.height(60.dp).widthIn(max = 200.dp) + ) + } else { + Text( + text = item.name, + color = Color.White, + fontSize = 28.sp, + fontWeight = FontWeight.Black, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = LocalTextStyle.current.copy( + shadow = Shadow(color = Color.Black.copy(alpha = 0.60f), offset = Offset(0f, 2f), blurRadius = 10f), + ), + ) + } + + Spacer(Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { if (item.communityRating != null && item.communityRating!! > 0) { Text( @@ -286,31 +265,15 @@ private fun FeaturedItemBanner( Text(text = "${item.productionYear}", color = Color.White.copy(alpha = 0.95f), fontSize = 12.sp) Spacer(Modifier.width(10.dp)) } - val typeLabel = if (item.type == "Series") "剧集" else "电影" - Text(text = typeLabel, color = Color.White.copy(alpha = 0.95f), fontSize = 12.sp) - } - Spacer(Modifier.height(6.dp)) - Text( - text = item.name, - color = Color.White, - fontSize = 22.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = LocalTextStyle.current.copy( - shadow = Shadow(color = Color.Black.copy(alpha = 0.60f), offset = Offset(0f, 2f), blurRadius = 10f), - ), - ) - Spacer(Modifier.height(4.dp)) - val genres = item.genres.take(3).joinToString(" / ") - if (genres.isNotBlank()) { - Text(text = genres, color = Color.White.copy(alpha = 0.88f), fontSize = 12.sp) + val genres = item.genres.take(2).joinToString(" / ") + if (genres.isNotBlank()) { + Text(text = genres, color = Color.White.copy(alpha = 0.88f), fontSize = 12.sp) + } } } } } -/* ---- Media Poster Card ---- */ @Composable private fun MediaPoster( item: EmbyItem, @@ -323,7 +286,7 @@ private fun MediaPoster( Column(modifier.width(115.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Box( - Modifier.fillMaxWidth().height(155.dp).clip(RoundedCornerShape(8.dp)) + Modifier.fillMaxWidth().height(165.dp).clip(RoundedCornerShape(12.dp)) .clickable { onItemClick(item) } .background(Brush.verticalGradient(fallbackColors)), ) { @@ -334,10 +297,6 @@ private fun MediaPoster( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) - } else { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text = "\u6682\u65E0\u5C01\u9762", color = Color.White.copy(alpha = 0.5f), fontSize = 11.sp) - } } } Text( @@ -351,7 +310,6 @@ private fun MediaPoster( } } -/* ---- Continue Watching Card (landscape backdrop) ---- */ @Composable private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClick: (EmbyItem) -> Unit = {}) { val backdropTag = item.backdropImageTags.firstOrNull() @@ -360,7 +318,7 @@ private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClic } ?: item.posterUrlPath?.let { "$serverUrl$it" } val fallbackColors = listOf(Color(0xFF37474F), Color(0xFF546E7A)) - Column(Modifier.width(216.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Column(Modifier.width(220.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { Box( Modifier.fillMaxWidth().aspectRatio(16f / 9f) .clip(RoundedCornerShape(12.dp)) @@ -374,44 +332,30 @@ private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClic contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) - } else { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(text = "暂无横版封面", color = Color.White.copy(alpha = 0.52f), fontSize = 11.sp) - } } Box( - Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.16f)), + Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.2f)), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Filled.PlayArrow, contentDescription = null, - tint = Color.White.copy(alpha = 0.82f), - modifier = Modifier.size(30.dp), + tint = Color.White, + modifier = Modifier.size(36.dp), ) } } Text( text = item.seriesName ?: item.name, color = homeContentColor(), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (item.seriesName != null && item.name != item.seriesName) { - Text( - text = item.name, - color = homeSecondaryColor(), - fontSize = 10.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } } } -/* ---- Library section whose items load only when it becomes visible ---- */ @Composable private fun LazyLibrarySection(folder: EmbyMediaFolder, activeServer: EmbyServer, onItemClick: (EmbyItem) -> Unit = {}) { var page by remember(folder.id) { mutableStateOf(null) } @@ -428,12 +372,11 @@ private fun LazyLibrarySection(folder: EmbyMediaFolder, activeServer: EmbyServer SectionTitle(title = folder.name, count = page?.totalCount ?: 0) val list = page?.items if (list == null) { - // skeleton placeholder while loading Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { repeat(3) { Box( - Modifier.width(115.dp).height(155.dp) - .clip(RoundedCornerShape(8.dp)) + Modifier.width(115.dp).height(165.dp) + .clip(RoundedCornerShape(12.dp)) .background(homeSecondaryColor().copy(alpha = 0.10f)) ) } @@ -448,108 +391,44 @@ private fun LazyLibrarySection(folder: EmbyMediaFolder, activeServer: EmbyServer } } -/* ---- Section Header with title + count + "更多 >" ---- */ @Composable private fun SectionTitle( title: String, count: Int = 0, - onMoreClick: (() -> Unit)? = null, ) { - val rainbowBrush = remember(title) { - val random = Random(title.hashCode()) - val basePalette = listOf( - Color(0xFFFF8FA3), // bright rose - Color(0xFFFFB570), // warm apricot - Color(0xFFFFE066), // vivid lemon - Color(0xFF7BE495), // fresh mint - Color(0xFF66C7FF), // clear sky - Color(0xFF9E8CFF), // periwinkle - Color(0xFFF783AC), // bright orchid - ) - val startIndex = random.nextInt(basePalette.size) - val colors = List(basePalette.size) { index -> - basePalette[(startIndex + index) % basePalette.size] - } - Brush.horizontalGradient(if (random.nextBoolean()) colors else colors.reversed()) - } Row( - Modifier.fillMaxWidth().padding(horizontal = 0.dp), + Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - Text(text = title, color = homeContentColor(), fontSize = 16.sp, fontWeight = FontWeight.Bold) - Text( - text = buildAnnotatedString { - if (count > 0) { - withStyle( - style = SpanStyle( - brush = rainbowBrush, - fontWeight = FontWeight.Bold, - ) - ) { - append(count.toString()) - } - append(" ") - } - withStyle(style = SpanStyle(brush = rainbowBrush, fontWeight = FontWeight.Bold)) { - append("\u66F4\u591A \u203A") - } - }, - fontSize = 13.sp, - ) - } -} - -/* ---- Library Section Row ---- */ -@Composable -private fun LibrarySectionRow(section: LibrarySection, serverUrl: String) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionTitle(title = section.folder.name, count = section.items.size) - if (section.items.isNotEmpty()) { - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - items(section.items) { item -> - MediaPoster(item = item, serverUrl = serverUrl) - } - } - } else { - Box( - Modifier.fillMaxWidth().height(155.dp).clip(RoundedCornerShape(10.dp)) - .background(GlassTokens.subtext.copy(alpha = 0.08f)), - contentAlignment = Alignment.Center, - ) { - Text(text = "\u6682\u65E0\u5185\u5BB9", color = homeSecondaryColor(), fontSize = 12.sp) - } + Text(text = title, color = homeContentColor(), fontSize = 17.sp, fontWeight = FontWeight.ExtraBold) + if (count > 0) { + Text( + text = "更多 >", + color = homeAccentColor(), + fontSize = 13.sp, + fontWeight = FontWeight.Bold + ) } } } -/* ---- My Media Row (horizontal scroll, same layout as Continue Watching) ---- */ @Composable private fun MyMediaGrid(libraries: List, serverUrl: String, onSelect: (EmbyMediaFolder) -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - SectionTitle(title = "我的媒体", count = libraries.size) - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + SectionTitle(title = "我的媒体") + LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { items(libraries) { folder -> - val isSelected = HomeUiState.selectedLibrary?.id == folder.id Column( - Modifier.width(168.dp), + Modifier.width(120.dp), + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - val folderImageUrl = "$serverUrl/emby/Items/${folder.id}/Images/Primary?maxWidth=400&quality=80" + val folderImageUrl = "$serverUrl/emby/Items/${folder.id}/Images/Primary?maxWidth=300&quality=80" Box( - Modifier.fillMaxWidth().height(104.dp) - .clip(RoundedCornerShape(12.dp)) - .border( - 1.dp, - Brush.linearGradient( - listOf( - Color.White.copy(alpha = 0.48f), - Color.White.copy(alpha = 0.30f), - Color.White.copy(alpha = 0.16f), - ) - ), - RoundedCornerShape(12.dp), - ) + Modifier.size(120.dp) + .clip(RoundedCornerShape(16.dp)) + .background(homeSecondaryColor().copy(alpha = 0.1f)) .clickable { onSelect(folder) }, ) { AsyncImage( @@ -563,30 +442,16 @@ private fun MyMediaGrid(libraries: List, serverUrl: String, onS text = folder.name, color = homeContentColor(), fontSize = 12.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + maxLines = 1 ) } } } } } -/* ---- Empty State ---- */ -@Composable -private fun EmptyHomeState() { - Column(Modifier.fillMaxWidth().padding(vertical = 80.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Box(Modifier.size(64.dp).clip(CircleShape).background(homeSecondaryColor().copy(alpha = 0.16f)), contentAlignment = Alignment.Center) { - Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null, tint = homeSecondaryColor(), modifier = Modifier.size(32.dp)) - } - Spacer(Modifier.height(16.dp)) - Text(text = "\u6CA1\u6709\u53EF\u7528\u7684\u5A92\u4F53\u5E93", color = homeSecondaryColor(), fontSize = 15.sp) - Spacer(Modifier.height(6.dp)) - Text(text = "\u8BF7\u5728\u8D44\u6E90\u5E93\u4E2D\u6DFB\u52A0\u670D\u52A1\u5668", color = homeSecondaryColor().copy(alpha = 0.72f), fontSize = 12.sp) - } -} -/* ================== Main HomeScreen ================== */ +@OptIn(ExperimentalAnimationApi::class) @Composable fun HomeScreen( servers: List = emptyList(), @@ -605,6 +470,7 @@ fun HomeScreen( var resumeItems by remember { mutableStateOf>(emptyList()) } var latestItems by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(false) } + val heroItems = remember(latestItems) { latestItems.take(8) } val heroPagerState = rememberPagerState(pageCount = { heroItems.size }) val activeHeroItem = heroItems.getOrNull(heroPagerState.currentPage) @@ -614,200 +480,124 @@ fun HomeScreen( if (activeServer == null || activeServer.apiKey.isBlank()) return@LaunchedEffect loading = true try { - // Fetch everything concurrently; commit state only when all data is ready val libsDeferred = async { EmbyRepository.getLibraries(activeServer) } val latestDeferred = async { EmbyRepository.getLatestItems(activeServer, limit = 10) } val resumeDeferred = async { EmbyRepository.getResumeItems(activeServer, limit = 10) } - val libs = libsDeferred.await() - val latest = latestDeferred.await() - val resume = resumeDeferred.await() - - HomeUiState.serverName = activeServer.name - if (HomeUiState.selectedLibrary == null || libs.none { it.id == HomeUiState.selectedLibrary?.id }) { - HomeUiState.selectedLibrary = libs.firstOrNull() - } - - // First screen renders immediately; per-library sections load on demand when scrolled into view - libraries = libs - latestItems = latest - resumeItems = resume + libraries = libsDeferred.await() + latestItems = latestDeferred.await() + resumeItems = resumeDeferred.await() } finally { loading = false } } - val surfaceIsDark = immersiveBackground.isDark - val adaptiveText = if (surfaceIsDark) Color(0xFFF4F7FB) else Color(0xFF121417) - val adaptiveSecondary = if (surfaceIsDark) Color.White.copy(alpha = 0.74f) else Color.Black.copy(alpha = 0.64f) - val adaptiveAccent = if (surfaceIsDark) Color(0xFF74B6FF) else Color(0xFF0A6DD4) - LaunchedEffect(surfaceIsDark) { - HomeUiState.immersiveSurfaceIsDark = surfaceIsDark - } + val adaptiveText = if (immersiveBackground.isDark) Color.White else Color.Black + val adaptiveSecondary = adaptiveText.copy(alpha = 0.7f) + val adaptiveAccent = if (immersiveBackground.isDark) Color(0xFF6EA8FF) else Color(0xFF0061A4) CompositionLocalProvider( LocalHomeContentColor provides adaptiveText, LocalHomeSecondaryColor provides adaptiveSecondary, LocalHomeAccentColor provides adaptiveAccent, ) { - Box(Modifier.fillMaxSize().background( - Brush.verticalGradient( - 0f to immersiveBackground.pageTop, - 0.53f to immersiveBackground.hero, - 1f to immersiveBackground.pageBottom, - ) - )) { - LazyColumn( - Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()), - verticalArrangement = Arrangement.spacedBy(0.dp), - ) { - if (activeServer == null) { - item { EmptyHomeState() } - } else if (loading) { - item { - Column( - Modifier.fillMaxWidth().padding(vertical = 80.dp), - horizontalAlignment = Alignment.CenterHorizontally, + Box(Modifier.fillMaxSize().background( + Brush.verticalGradient( + listOf(immersiveBackground.hero, immersiveBackground.pageBottom) + ) + )) { + AnimatedContent( + targetState = loading, + transitionSpec = { + fadeIn(tween(500)) togetherWith fadeOut(tween(500)) + }, + label = "HomeLoadingTransition" + ) { isLoading -> + if (isLoading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = adaptiveAccent) + } + } else { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()) ) { - Text(text = "加载中...", color = homeSecondaryColor(), fontSize = 14.sp) - } - } - } else { - // 1) Hero poster-wall carousel (full-bleed under status bar) - if (latestItems.isNotEmpty()) { - item { - HeroCarousel( - items = heroItems, - serverUrl = activeServer.fullUrl, - pagerState = heroPagerState, - immersiveBackground = immersiveBackground, - onItemClick = { detailItem = it }, - ) - } - } + if (heroItems.isNotEmpty()) { + item { + HeroCarousel( + items = heroItems, + serverUrl = activeServer!!.fullUrl, + pagerState = heroPagerState, + immersiveBackground = immersiveBackground, + onItemClick = { detailItem = it } + ) + } + } - // 2) Continue Watching - if (resumeItems.isNotEmpty()) { - item { - Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp)) { - SectionTitle(title = "继续观看") - Spacer(Modifier.height(8.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - items(resumeItems) { item -> - ContinueWatchingPoster(item = item, serverUrl = activeServer.fullUrl) { detailItem = it } + item { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) { + if (resumeItems.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SectionTitle(title = "继续观看") + LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + items(resumeItems) { item -> + ContinueWatchingPoster(item, activeServer!!.fullUrl) { detailItem = it } + } + } + } + } + + if (libraries.isNotEmpty()) { + MyMediaGrid(libraries, activeServer!!.fullUrl) { folder -> + // Handle selection + } + } + + libraries.forEach { folder -> + LazyLibrarySection(folder, activeServer!!) { detailItem = it } } } } } } - - // 3) My Media Grid (tap to select -> top bar updates) - if (libraries.isNotEmpty()) { - item { - Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp)) { - MyMediaGrid(libraries = libraries, serverUrl = activeServer.fullUrl, onSelect = { folder -> - HomeUiState.selectedLibrary = folder - }) - } - } - } - - // 4) Per-library poster sections - libraries.forEach { folder -> - item(key = "section-" + folder.id) { - Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp)) { - LazyLibrarySection(folder = folder, activeServer = activeServer) { detailItem = it } - } - } - } } - } - // Overlay top bar: selected library name + matching icon, floats over poster - HomeOverlayTopBar( - name = activeServer?.name ?: "", - iconUrl = activeServer?.iconUrl ?: "", - modifier = Modifier.align(Alignment.TopCenter), - ) + HomeOverlayTopBar( + name = activeServer?.name ?: "未连接", + iconUrl = activeServer?.iconUrl ?: "", + modifier = Modifier.align(Alignment.TopCenter) + ) - detailItem?.let { item -> - activeServer?.let { server -> + detailItem?.let { item -> MediaDetailScreen( item = item, - server = server, - onClose = { detailItem = null }, + server = activeServer!!, + onClose = { detailItem = null } ) } } } - } } -/* ---- Overlay top bar showing selected server (live name + custom icon) ---- */ @Composable -private fun HomeOverlayTopBar( - name: String, - iconUrl: String, - modifier: Modifier = Modifier, -) { - val title = name.ifBlank { "个人媒体中心" } - Row( - modifier.fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { +private fun HomeOverlayTopBar(name: String, iconUrl: String, modifier: Modifier = Modifier) { + LiquidGlassTopBar(modifier) { Row( - Modifier.widthIn(min = 168.dp) - .clip(RoundedCornerShape(22.dp)) - .background( - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = 0.42f), - Color.White.copy(alpha = 0.28f), - ) - ) - ) - .border(0.8.dp, Color.White.copy(alpha = 0.60f), RoundedCornerShape(22.dp)) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { - if (iconUrl.isNotBlank()) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(iconUrl).crossfade(true).build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.size(22.dp).clip(RoundedCornerShape(6.dp)), - ) - } else { - Icon(imageVector = Icons.Outlined.Storage, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp)) + Box( + Modifier.size(36.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center + ) { + if (iconUrl.isNotEmpty()) { + AsyncImage(model = iconUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + } else { + Icon(Icons.Outlined.Storage, null, tint = Color.White) + } } - Spacer(Modifier.width(7.dp)) - Text( - text = title, - color = Color.White, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = LocalTextStyle.current.copy( - shadow = Shadow(color = Color.Black.copy(alpha = 0.45f), offset = Offset(0f, 1f), blurRadius = 6f), - ), - ) + Spacer(Modifier.width(12.dp)) + Text(name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold) } - - } } - - - - - - - - - - - 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 cb2e8da..3e760cf 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -96,9 +96,6 @@ import coil.request.ImageRequest import kotlinx.coroutines.delay import kotlinx.coroutines.launch -private val serverTints = listOf( - Color(0xFF9CC4FF), Color(0xFFA8E6C0), Color(0xFFD6B8FF), Color(0xFFFFB8D0), -) private val onlineGreen = Color(0xFF34C759) private val offlineRed = Color(0xFFFF3B30) @@ -116,148 +113,81 @@ fun LibraryTopBar( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text(text = "\u8D44\u6E90\u5E93", color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Text(text = "资源库", color = GlassTokens.text, fontSize = 20.sp, fontWeight = FontWeight.Bold) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Box(Modifier.size(38.dp).clip(CircleShape).background(GlassTokens.glassFill).border(1.dp, GlassTokens.glassBorder, CircleShape).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onViewToggle() }, contentAlignment = Alignment.Center) { - Icon(imageVector = if (isGrid) Icons.Filled.GridView else Icons.Filled.ViewAgenda, contentDescription = "\u89C6\u56FE\u5207\u6362", tint = GlassTokens.text, modifier = Modifier.size(21.dp)) + Box(Modifier.size(38.dp).clip(CircleShape).background(Color.White).border(0.5.dp, Color.LightGray, CircleShape).clickable { onViewToggle() }, contentAlignment = Alignment.Center) { + Icon(imageVector = if (isGrid) Icons.Filled.GridView else Icons.Filled.ViewAgenda, contentDescription = null, tint = Color(0xFF374151), modifier = Modifier.size(20.dp)) } - Box(Modifier.size(38.dp).clip(CircleShape).background(GlassTokens.glassFill).border(1.dp, GlassTokens.glassBorder, CircleShape).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onOpenIconLibraryManager() }, contentAlignment = Alignment.Center) { - Icon(imageVector = Icons.Outlined.PhotoLibrary, contentDescription = "\u56FE\u6807\u5E93\u7BA1\u7406", tint = GlassTokens.text, modifier = Modifier.size(21.dp)) + Box(Modifier.size(38.dp).clip(CircleShape).background(Color.White).border(0.5.dp, Color.LightGray, CircleShape).clickable { onOpenIconLibraryManager() }, contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Outlined.PhotoLibrary, contentDescription = null, tint = Color(0xFF374151), modifier = Modifier.size(20.dp)) } - Box(Modifier.size(38.dp).clip(CircleShape).background(GlassTokens.glassFill).border(1.dp, GlassTokens.glassBorder, CircleShape).clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onAdd() }, contentAlignment = Alignment.Center) { - Icon(imageVector = Icons.Outlined.Add, contentDescription = "\u6DFB\u52A0\u670D\u52A1\u5668", tint = GlassTokens.text, modifier = Modifier.size(21.dp)) + Box(Modifier.size(38.dp).clip(CircleShape).background(Color.White).border(0.5.dp, Color.LightGray, CircleShape).clickable { onAdd() }, contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Outlined.Add, contentDescription = null, tint = Color(0xFF374151), modifier = Modifier.size(20.dp)) } } } } } -/* ------------------------------------------------------------------ */ -/* Icon Library Section */ -/* ------------------------------------------------------------------ */ - @Composable -private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit) { +private fun ServerCard(server: EmbyServer, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit) { var showPopup by remember { mutableStateOf(false) } - var clicked by remember { mutableStateOf(false) } - val pressBg by animateColorAsState(targetValue = if (clicked) Color.White.copy(alpha = 0.18f) else Color.Transparent, animationSpec = tween(durationMillis = if (clicked) 80 else 200), label = "pb") - LaunchedEffect(clicked) { if (clicked) { delay(150); clicked = false } } - GlassSurface(modifier = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { clicked = true; onClick() }, fill = tint.copy(alpha = 0.50f), cornerRadius = 20.dp, padding = PaddingValues(0.dp)) { - Row(modifier = Modifier.fillMaxWidth().background(pressBg).padding(14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) { - if (server.iconUrl.isNotBlank()) { - AsyncImage(model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(), contentDescription = null, modifier = Modifier.size(46.dp).clip(RoundedCornerShape(14.dp)), contentScale = ContentScale.Crop) - } else { - Box(Modifier.size(46.dp).clip(RoundedCornerShape(14.dp)).background(Brush.linearGradient(listOf(tint, tint.copy(alpha = 0.7f)))), contentAlignment = Alignment.Center) { - Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp)) - } - } - Spacer(Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = server.name.ifBlank { server.displayHost }, color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) - Spacer(Modifier.width(7.dp)) - Box(Modifier.size(8.dp).clip(CircleShape).background(if (server.isOnline) onlineGreen else offlineRed)) - } - if (!server.isOnline && server.authError.isNotBlank()) { - Text(text = server.authError, color = offlineRed, fontSize = 11.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) - } else { - Text(text = if (server.isOnline) server.lastActive.ifEmpty { "\u5728\u7EBF" } else "\u79BB\u7EBF", color = GlassTokens.subtext, fontSize = 12.sp) - } - if (server.note.isNotBlank()) { - Text( - text = server.note, - color = GlassTokens.subtext, - fontSize = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 2.dp), - ) - } + GlassSurface(modifier = Modifier.clickable { onClick() }, fill = Color.White, cornerRadius = 16.dp, padding = PaddingValues(12.dp), border = Color.LightGray) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (server.iconUrl.isNotBlank()) { + AsyncImage(model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(), contentDescription = null, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(10.dp)), contentScale = ContentScale.Crop) + } else { + Box(Modifier.size(44.dp).clip(RoundedCornerShape(10.dp)).background(Color(0xFFF3F4F6)), contentAlignment = Alignment.Center) { + Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null, tint = Color(0xFF374151), modifier = Modifier.size(24.dp)) } } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = server.name.ifBlank { server.displayHost }, color = Color(0xFF1F2937), fontSize = 15.sp, fontWeight = FontWeight.Bold) + Text(text = if (server.isOnline) "在线" else "离线", color = if (server.isOnline) onlineGreen else offlineRed, fontSize = 11.sp) + } 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 = null, tint = Color(0xFF6B7280), modifier = Modifier.size(28.dp).clickable { showPopup = true }) + GlassPopup(expanded = showPopup, onDismissRequest = { showPopup = false }) { + GlassPopupItem(text = "编辑", icon = Icons.Outlined.Edit, onClick = { showPopup = false; onEdit() }) + GlassPopupItem(text = "更换图标", icon = Icons.Outlined.Image, onClick = { showPopup = false; onChangeIcon() }) + GlassPopupItem(text = "删除", icon = Icons.Outlined.Delete, tint = offlineRed, onClick = { showPopup = false; onDelete() }) + } } } } } - -/* ------------------------------------------------------------------ */ -/* Icon Library Section */ -/* ------------------------------------------------------------------ */ - @Composable -private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit) { +private fun ServerGridCard(server: EmbyServer, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit) { var showPopup by remember { mutableStateOf(false) } - var clicked by remember { mutableStateOf(false) } - val pressBg by animateColorAsState(targetValue = if (clicked) Color.White.copy(alpha = 0.18f) else Color.Transparent, animationSpec = tween(durationMillis = if (clicked) 80 else 200), label = "pb") - LaunchedEffect(clicked) { if (clicked) { delay(150); clicked = false } } - GlassSurface(modifier = Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { clicked = true; onClick() }, fill = tint.copy(alpha = 0.50f), cornerRadius = 20.dp, padding = PaddingValues(0.dp)) { - Column(modifier = Modifier.fillMaxWidth().background(pressBg).padding(14.dp), horizontalAlignment = Alignment.Start) { + GlassSurface(modifier = Modifier.clickable { onClick() }, fill = Color.White, cornerRadius = 16.dp, padding = PaddingValues(12.dp), border = Color.LightGray) { + Column(modifier = Modifier.fillMaxWidth()) { Row(verticalAlignment = Alignment.CenterVertically) { if (server.iconUrl.isNotBlank()) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(), - contentDescription = null, - modifier = Modifier.size(48.dp).clip(RoundedCornerShape(14.dp)), - contentScale = ContentScale.Crop, - ) + AsyncImage(model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(), contentDescription = null, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)), contentScale = ContentScale.Crop) } else { - Box( - Modifier.size(48.dp).clip(RoundedCornerShape(14.dp)).background(Brush.linearGradient(listOf(tint, tint.copy(alpha = 0.7f)))), - contentAlignment = Alignment.Center, - ) { - Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp)) - } - } - Spacer(Modifier.width(10.dp)) - Column(Modifier.weight(1f)) { - Text( - text = server.name.ifBlank { server.displayHost }, - color = GlassTokens.text, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Spacer(Modifier.height(3.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.size(7.dp).clip(CircleShape).background(if (server.isOnline) onlineGreen else offlineRed)) - Spacer(Modifier.width(4.dp)) - if (!server.isOnline && server.authError.isNotBlank()) { - Text(text = server.authError, color = offlineRed, fontSize = 10.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) - } else { - Text(text = if (server.isOnline) server.lastActive.ifEmpty { "\u5728\u7EBF" } else "\u79BB\u7EBF", color = GlassTokens.subtext, fontSize = 11.sp) - } + Box(Modifier.size(40.dp).clip(RoundedCornerShape(8.dp)).background(Color(0xFFF3F4F6)), contentAlignment = Alignment.Center) { + Icon(Icons.Filled.PlayArrow, null, tint = Color(0xFF374151), modifier = Modifier.size(20.dp)) } } + Spacer(Modifier.weight(1f)) 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(Icons.Outlined.MoreHoriz, null, tint = Color(0xFF6B7280), modifier = Modifier.size(24.dp).clickable { showPopup = true }) + GlassPopup(expanded = showPopup, onDismissRequest = { showPopup = false }) { + GlassPopupItem(text = "编辑", icon = Icons.Outlined.Edit, onClick = { showPopup = false; onEdit() }) + GlassPopupItem(text = "更换图标", icon = Icons.Outlined.Image, onClick = { showPopup = false; onChangeIcon() }) + GlassPopupItem(text = "删除", icon = Icons.Outlined.Delete, tint = offlineRed, onClick = { showPopup = false; onDelete() }) + } } } - if (server.note.isNotBlank()) { - Text( - text = server.note, - color = GlassTokens.subtext, - fontSize = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 8.dp), - ) - } + Spacer(Modifier.height(8.dp)) + Text(text = server.name.ifBlank { server.displayHost }, color = Color(0xFF1F2937), fontSize = 14.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = if (server.isOnline) "在线" else "离线", color = if (server.isOnline) onlineGreen else offlineRed, fontSize = 10.sp) } } } - -/* ------------------------------------------------------------------ */ -/* Icon Library Section */ -/* ------------------------------------------------------------------ */ - @Composable fun LibraryScreen( servers: List, @@ -277,63 +207,31 @@ fun LibraryScreen( var editingServer by remember { mutableStateOf(null) } if (showAddDialog) { - AddServerDialog(onDismiss = onDismissAddDialog, onConfirm = { srv, onResult -> - onAddServer(srv, onResult) - }) + AddServerDialog(onDismiss = onDismissAddDialog, onConfirm = { srv, onResult -> onAddServer(srv, onResult) }) } if (editingServer != null) { - AddServerDialog( - initial = editingServer, - onDismiss = { editingServer = null }, - onConfirm = { updated, onResult -> - onUpdateServer(updated, onResult) - }, - ) + AddServerDialog(initial = editingServer, onDismiss = { editingServer = null }, onConfirm = { updated, onResult -> onUpdateServer(updated, onResult) }) } if (onChangeIconTarget != null) { - IconPickerDialog( - server = onChangeIconTarget, - onDismiss = onDismissChangeIcon, - onPick = { srv, url -> onPickIcon(srv, url); onDismissChangeIcon() }, - ) + IconPickerDialog(server = onChangeIconTarget, onDismiss = onDismissChangeIcon, onPick = { srv, url -> onPickIcon(srv, url); onDismissChangeIcon() }) } LazyVerticalGrid( columns = if (isGrid) GridCells.Fixed(2) else GridCells.Fixed(1), - modifier = Modifier.fillMaxSize().padding(horizontal = if (isGrid) 16.dp else 0.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), contentPadding = PaddingValues(top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), - verticalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 10.dp), - horizontalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 0.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - // Servers section - items(servers.size) { index -> - val server = servers[index] - val tint = serverTints[index % serverTints.size] + items(servers) { server -> if (isGrid) { - ServerGridCard( - server = server, tint = tint, - onClick = { onSelectServer(server.id) }, - onChangeIcon = { onRequestChangeIcon(server) }, - onDelete = { onDeleteServer(server.id) }, - onEdit = { editingServer = server }, - ) + ServerGridCard(server = server, onClick = { onSelectServer(server.id) }, onChangeIcon = { onRequestChangeIcon(server) }, onDelete = { onDeleteServer(server.id) }, onEdit = { editingServer = server }) } else { - ServerCard( - server = server, tint = tint, - onClick = { onSelectServer(server.id) }, - onChangeIcon = { onRequestChangeIcon(server) }, - onDelete = { onDeleteServer(server.id) }, - onEdit = { editingServer = server }, - ) + ServerCard(server = server, onClick = { onSelectServer(server.id) }, onChangeIcon = { onRequestChangeIcon(server) }, onDelete = { onDeleteServer(server.id) }, onEdit = { editingServer = server }) } } } } - -/* ------------------------------------------------------------------ */ -/* Icon Library Section */ -/* ------------------------------------------------------------------ */ - @Composable fun IconLibraryManagerScreen( libraries: List, @@ -346,1165 +244,128 @@ fun IconLibraryManagerScreen( ) { var showAddDialog by remember { mutableStateOf(false) } var editingLibraryId by remember { mutableStateOf(null) } - - val editingLibrary = editingLibraryId?.let { id -> libraries.find { it.id == id } } Column(modifier.fillMaxSize()) { LiquidGlassTopBar { - Row( - Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box(Modifier.width(86.dp)) { - Row( - Modifier.clip(RoundedCornerShape(20.dp)) - .background(GlassTokens.accent.copy(alpha = 0.15f)) - .border(1.dp, GlassTokens.accent.copy(alpha = 0.38f), RoundedCornerShape(20.dp)) - .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { showAddDialog = true } - .padding(horizontal = 10.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(3.dp), - ) { - Icon(Icons.Outlined.Add, contentDescription = null, tint = GlassTokens.accent, modifier = Modifier.size(17.dp)) - Text("\u6DFB\u52A0", color = GlassTokens.accent, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) - } + Row(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(34.dp).clip(CircleShape).background(Color.White).border(0.5.dp, Color.LightGray, CircleShape).clickable { onBack() }, contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Close, null, tint = Color(0xFF374151), modifier = Modifier.size(20.dp)) } - Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { - Text("\u56FE\u6807\u5E93\u7BA1\u7406", color = GlassTokens.text, fontSize = 19.sp, fontWeight = FontWeight.Bold) - } - Box(Modifier.width(86.dp), contentAlignment = Alignment.CenterEnd) { - Box( - Modifier.size(34.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.42f)) - .border(1.dp, Color.White.copy(alpha = 0.55f), CircleShape) - .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onBack() }, - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Close, contentDescription = "\u8FD4\u56DE", tint = GlassTokens.text, modifier = Modifier.size(19.dp)) - } + Spacer(Modifier.width(16.dp)) + Text("图标库管理", color = GlassTokens.text, fontSize = 18.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Box(Modifier.size(34.dp).clip(CircleShape).background(Color.White).border(0.5.dp, Color.LightGray, CircleShape).clickable { showAddDialog = true }, contentAlignment = Alignment.Center) { + Icon(Icons.Outlined.Add, null, tint = Color(0xFF374151), modifier = Modifier.size(20.dp)) } } } - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), - contentPadding = PaddingValues(top = 16.dp, bottom = 28.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (libraries.isEmpty()) { - item(span = { GridItemSpan(2) }) { - Box( - Modifier.fillMaxWidth() - .clip(RoundedCornerShape(22.dp)) - .background(Color.White.copy(alpha = 0.45f)) - .border(0.5.dp, Color.White.copy(alpha = 0.58f), RoundedCornerShape(22.dp)) - .padding(vertical = 54.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Outlined.Image, contentDescription = null, tint = GlassTokens.subtext, modifier = Modifier.size(32.dp)) - Text("\u8FD8\u6CA1\u6709\u56FE\u6807\u5E93", color = GlassTokens.subtext, fontSize = 15.sp) - Text("\u70B9\u51FB\u5DE6\u4E0A\u89D2\u6DFB\u52A0", color = GlassTokens.subtext.copy(alpha = 0.75f), fontSize = 12.sp) - } - } - } - } else { - items(libraries) { library -> - IconLibraryManagerCard(library = library, onClick = { editingLibraryId = library.id }) - } + LazyVerticalGrid(columns = GridCells.Fixed(2), modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), contentPadding = PaddingValues(top = 16.dp, bottom = 28.dp), verticalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items(libraries) { library -> + IconLibraryManagerCard(library = library, onClick = { editingLibraryId = library.id }) } } } if (showAddDialog) { - AddIconLibraryDialog( - onDismiss = { showAddDialog = false }, - onSave = { name, url, icons, isDefault -> - onAddIconLibrary(name, url, icons, isDefault) - showAddDialog = false - }, - ) + AddIconLibraryDialog(onDismiss = { showAddDialog = false }, onSave = { name, url, icons, isDefault -> onAddIconLibrary(name, url, icons, isDefault); showAddDialog = false }) } if (editingLibrary != null) { - ManageIconLibraryDialog( - library = editingLibrary, - onDismiss = { editingLibraryId = null }, - onUpdate = { updated -> - onUpdateIconLibrary(updated) - editingLibraryId = null - }, - onDelete = { - onDeleteIconLibrary(editingLibrary.id) - editingLibraryId = null - }, - onRefresh = { onRefreshIconLibrary(editingLibrary.id) }, - ) + ManageIconLibraryDialog(library = editingLibrary, onDismiss = { editingLibraryId = null }, onUpdate = { updated -> onUpdateIconLibrary(updated); editingLibraryId = null }, onDelete = { onDeleteIconLibrary(editingLibrary.id); editingLibraryId = null }, onRefresh = { onRefreshIconLibrary(editingLibrary.id) }) } } @Composable private fun IconLibraryManagerCard(library: IconLibrary, onClick: () -> Unit) { - Box( - Modifier.fillMaxWidth() - .clip(RoundedCornerShape(20.dp)) - .background( - Brush.verticalGradient( - 0f to Color.White.copy(alpha = 0.74f), - 1f to Color.White.copy(alpha = 0.50f), - ) - ) - .border(0.5.dp, Color.White.copy(alpha = 0.62f), RoundedCornerShape(20.dp)) - .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick) - .padding(14.dp), - ) { + Box(Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(Color.White).border(0.5.dp, Color.LightGray, RoundedCornerShape(16.dp)).clickable { onClick() }.padding(12.dp)) { Column { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box( - Modifier.size(38.dp).clip(RoundedCornerShape(12.dp)) - .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(19.dp)) - } - Column(Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { - Text(library.name, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) - if (library.isDefault) { - Box( - Modifier.clip(RoundedCornerShape(50)) - .background(GlassTokens.accent.copy(alpha = 0.14f)) - .border(0.5.dp, GlassTokens.accent.copy(alpha = 0.35f), RoundedCornerShape(50)) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Text(stringResource(Res.string.icon_default_badge), color = GlassTokens.accent, fontSize = 10.sp, fontWeight = FontWeight.SemiBold) - } - } - } - Text("${library.iconCount} \u4E2A\u56FE\u6807", color = GlassTokens.subtext, fontSize = 11.sp) - } - } - - Spacer(Modifier.height(11.dp)) - Row(horizontalArrangement = Arrangement.spacedBy((-5).dp), verticalAlignment = Alignment.CenterVertically) { - library.icons.take(4).forEach { icon -> - Box( - Modifier.size(34.dp).clip(RoundedCornerShape(9.dp)) - .background(Color.White.copy(alpha = 0.55f)) - .border(0.5.dp, Color.White.copy(alpha = 0.60f), RoundedCornerShape(9.dp)), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), - contentDescription = icon.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(6.dp)), - ) - } - } - if (library.iconCount > 0 && library.icons.isEmpty()) { - Box( - Modifier.weight(1f).height(34.dp).clip(RoundedCornerShape(9.dp)) - .background(Color.White.copy(alpha = 0.30f)), - contentAlignment = Alignment.Center, - ) { Text("\u56fe\u6807\u672a\u52a0\u8f7d", color = GlassTokens.subtext, fontSize = 11.sp) } - } else if (library.icons.isEmpty()) { - Box( - Modifier.weight(1f).height(34.dp).clip(RoundedCornerShape(9.dp)) - .background(Color.White.copy(alpha = 0.30f)), - contentAlignment = Alignment.Center, - ) { Text("\u6682\u65E0\u56FE\u6807", color = GlassTokens.subtext, fontSize = 11.sp) } - } else if (library.iconCount > 4) { - Box( - Modifier.size(34.dp).clip(RoundedCornerShape(9.dp)) - .background(GlassTokens.accent.copy(alpha = 0.12f)), - contentAlignment = Alignment.Center, - ) { Text("+${library.iconCount - 4}", color = GlassTokens.accent, fontSize = 10.sp, fontWeight = FontWeight.Bold) } - } - } - - Spacer(Modifier.height(11.dp)) - Box( - Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)) - .background(Color.White.copy(alpha = 0.35f)) - .border(0.5.dp, Color.White.copy(alpha = 0.48f), RoundedCornerShape(12.dp)) - .padding(vertical = 7.dp), - contentAlignment = Alignment.Center, - ) { - Text("\u9884\u89C8 \u00B7 \u4FEE\u6539", color = GlassTokens.text, fontSize = 12.sp, fontWeight = FontWeight.Medium) - } + Text(library.name, color = Color(0xFF1F2937), fontSize = 14.sp, fontWeight = FontWeight.Bold) + Text("${library.iconCount} 图标", color = Color(0xFF6B7280), fontSize = 11.sp) } } } -@Composable -private fun IconLibrarySection( - libraries: List, - isGrid: Boolean, - onClick: (IconLibrary) -> Unit, - onManage: (IconLibrary) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text("\u56FE\u6807\u5E93", fontSize = 15.sp, fontWeight = FontWeight.Bold, color = GlassTokens.text) - Text("${libraries.size} \u4E2A", fontSize = 13.sp, color = GlassTokens.subtext) - } - if (isGrid) { - // Grid layout for icon libraries — use Column+Row to avoid nested scrolling - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - libraries.chunked(2).forEach { row -> - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - row.forEach { lib -> - IconLibraryCard( - library = lib, - modifier = Modifier.weight(1f), - onClick = { onClick(lib) }, - onLongClick = { onManage(lib) }, - ) - } - // Fill remaining space if odd number - if (row.size < 2) { - Spacer(Modifier.weight(1f)) - } - } - } - } - } else { - // List layout for icon libraries — use Row to avoid nested scrolling - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - libraries.forEach { lib -> - IconLibraryCard( - library = lib, - modifier = Modifier.width(140.dp), - onClick = { onClick(lib) }, - onLongClick = { onManage(lib) }, - ) - } - } - } - } -} - -@Composable -private fun IconLibraryCard( - library: IconLibrary, - modifier: Modifier = Modifier, - onClick: () -> Unit, - onLongClick: () -> Unit, -) { - Box( - modifier = modifier - .clip(RoundedCornerShape(18.dp)) - .background( - Brush.verticalGradient( - 0f to Color.White.copy(alpha = 0.72f), - 1f to Color.White.copy(alpha = 0.48f), - ) - ) - .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(18.dp)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onClick, - ) - .padding(12.dp), - ) { - Column { - // Icon preview row - Row( - horizontalArrangement = Arrangement.spacedBy((-6).dp), - verticalAlignment = Alignment.CenterVertically, - ) { - library.icons.take(3).forEach { icon -> - Box( - Modifier.size(30.dp).clip(RoundedCornerShape(8.dp)) - .background(Brush.verticalGradient(listOf(Color(0xFFFF6B9D).copy(alpha = 0.3f), Color(0xFFFF8A65).copy(alpha = 0.3f)))) - .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), - contentDescription = icon.name, - contentScale = ContentScale.Crop, - modifier = Modifier.size(24.dp).clip(RoundedCornerShape(6.dp)), - ) - } - } - if (library.iconCount > 3) { - Box( - Modifier.size(30.dp).clip(RoundedCornerShape(8.dp)) - .background(Brush.verticalGradient(listOf(Color(0xFF9B9B9B).copy(alpha = 0.2f), Color(0xFF9B9B9B).copy(alpha = 0.1f)))) - .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(8.dp)), - contentAlignment = Alignment.Center, - ) { - Text("+${library.iconCount - 3}", fontSize = 10.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) - } - } - } - Spacer(Modifier.height(8.dp)) - Text(library.name, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = GlassTokens.text, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text("${library.iconCount} \u4E2A\u56FE\u6807", fontSize = 11.sp, color = GlassTokens.subtext) - Spacer(Modifier.height(6.dp)) - // Manage button - Box( - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(10.dp)) - .background(Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.12f), GlassTokens.accent.copy(alpha = 0.06f)))) - .border(0.5.dp, GlassTokens.accent.copy(alpha = 0.2f), RoundedCornerShape(10.dp)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onLongClick, - ) - .padding(vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Text("\u7BA1\u7406", fontSize = 12.sp, fontWeight = FontWeight.Medium, color = GlassTokens.accent) - } - } - } -} - -/* ------------------------------------------------------------------ */ -/* Icon Library Detail Dialog — browse icons */ -/* ------------------------------------------------------------------ */ - -@Composable -private fun IconLibraryDetailDialog(library: IconLibrary, onDismiss: () -> Unit) { - Dialog(onDismissRequest = onDismiss) { - Box( - Modifier - .width(340.dp) - .shadow(24.dp, RoundedCornerShape(28.dp), ambientColor = Color.White.copy(alpha = 0.18f), spotColor = Color.Black.copy(alpha = 0.05f)) - .clip(RoundedCornerShape(28.dp)) - .background( - Brush.verticalGradient(0f to Color.White.copy(alpha = 0.88f), 0.5f to Color.White.copy(alpha = 0.75f), 1f to Color.White.copy(alpha = 0.65f)) - ) - .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(28.dp)) - .padding(20.dp) - ) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - // Header - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)) - .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) - } - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text(library.name, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text) - Text("${library.iconCount} \u4E2A\u56FE\u6807", fontSize = 12.sp, color = GlassTokens.subtext) - } - } - - if (library.iconCount > 0 && library.icons.isEmpty()) { - Box( - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f))) - .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp)) - .padding(vertical = 36.dp), - contentAlignment = Alignment.Center, - ) { Text("\u56fe\u6807\u672a\u52a0\u8f7d", color = GlassTokens.subtext, fontSize = 14.sp) } - } else if (library.icons.isEmpty()) { - Box( - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f))) - .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp)) - .padding(vertical = 36.dp), - contentAlignment = Alignment.Center, - ) { Text("\u6682\u65E0\u56FE\u6807", color = GlassTokens.subtext, fontSize = 14.sp) } - } else { - Box( - Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f))) - .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp)) - .padding(8.dp) - ) { - LazyVerticalGrid( - columns = GridCells.Fixed(5), - modifier = Modifier.heightIn(min = 120.dp, max = 400.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - items(library.icons) { icon -> - Box( - modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(10.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.55f), 1f to Color.White.copy(alpha = 0.35f))) - .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(10.dp)) - .padding(4.dp), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), - contentDescription = icon.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(8.dp)), - ) - } - } - } - } - } - - // Close button - Box( - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f))) - .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) - .clickable { onDismiss() } - .padding(vertical = 14.dp), - contentAlignment = Alignment.Center, - ) { Text("\u5173\u95ED", color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) } - } - } - } -} - -/* ------------------------------------------------------------------ */ -/* Manage Icon Library Dialog — rename, refresh, delete */ -/* ------------------------------------------------------------------ */ - -@Composable -private fun ManageIconLibraryDialog( - library: IconLibrary, - onDismiss: () -> Unit, - onUpdate: (IconLibrary) -> Unit, - onDelete: () -> Unit, - onRefresh: () -> Unit, -) { - var editName by remember { mutableStateOf(TextFieldValue(library.name)) } - var editUrl by remember { mutableStateOf(TextFieldValue(library.sourceUrl)) } - var setAsDefault by remember { mutableStateOf(library.isDefault) } - var isRefreshing by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - GlassDialog(onDismissRequest = onDismiss) { - Column( - modifier = Modifier.width(300.dp) - .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.78f).dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - // Header - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)) - .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) - } - Spacer(Modifier.width(10.dp)) - Text(text = "\u7BA1\u7406\u56FE\u6807\u5E93", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text) - } - - // Info - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Column { - Text("\u56FE\u6807\u6570\u91CF", fontSize = 12.sp, color = GlassTokens.subtext) - Text("${library.iconCount}", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = GlassTokens.text) - } - } - - // Editable library metadata - GlassInputField(label = "\u56FE\u6807\u5E93\u540D\u79F0", value = editName, placeholder = "\u56FE\u6807\u5E93\u540D\u79F0", onValueChange = { editName = it }) - GlassInputField(label = "\u56FE\u6807\u5E93\u5730\u5740 (JSON)", value = editUrl, placeholder = "https://...", onValueChange = { editUrl = it }) - - // Default library switch - Row( - Modifier.fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(if (setAsDefault) GlassTokens.accent.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.35f)) - .border(0.5.dp, if (setAsDefault) GlassTokens.accent.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp)) - .clickable { setAsDefault = !setAsDefault } - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(stringResource(Res.string.set_default_library), color = GlassTokens.text, fontSize = 14.sp, fontWeight = FontWeight.Medium) - Text(stringResource(Res.string.set_default_library_sub), color = GlassTokens.subtext, fontSize = 11.sp) - } - Box( - Modifier.size(width = 42.dp, height = 25.dp) - .clip(CircleShape) - .background(if (setAsDefault) GlassTokens.accent else Color.White.copy(alpha = 0.65f)) - .border(0.5.dp, if (setAsDefault) Color.Transparent else Color.White.copy(alpha = 0.75f), CircleShape) - .padding(3.dp), - contentAlignment = if (setAsDefault) Alignment.CenterEnd else Alignment.CenterStart, - ) { - Box(Modifier.size(19.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.92f))) - } - } - - // Icon preview - if (library.icons.isNotEmpty()) { - Text("\u56FE\u6807\u9884\u89C8", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) - LazyVerticalGrid( - columns = GridCells.Fixed(6), - modifier = Modifier.heightIn(min = 80.dp, max = 180.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(library.icons) { icon -> - Box( - modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(8.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.55f), 1f to Color.White.copy(alpha = 0.35f))) - .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(8.dp)) - .padding(2.dp), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), - contentDescription = icon.name, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(6.dp)), - ) - } - } - } - } - - // Refresh button - Box( - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.50f), 1f to Color.White.copy(alpha = 0.30f))) - .border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp)) - .clickable(enabled = !isRefreshing) { - isRefreshing = true - scope.launch { - onRefresh() - delay(500) - isRefreshing = false - } - } - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center, - ) { - if (isRefreshing) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), color = GlassTokens.accent, strokeWidth = 2.dp) - } else { - Text("\u5237\u65B0\u56FE\u6807", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = GlassTokens.accent) - } - } - - // Save / Delete row - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - GlassButton( - text = "\u5220\u9664", - onClick = onDelete, - modifier = Modifier.weight(1f), - danger = true, - ) - GlassButton( - text = "\u4FDD\u5B58", - onClick = { - if (editName.text.isNotBlank() && editUrl.text.isNotBlank()) { - onUpdate(library.copy(name = editName.text.trim(), sourceUrl = editUrl.text.trim(), isDefault = setAsDefault)) - } - }, - modifier = Modifier.weight(1f), - accent = true, - enabled = editName.text.isNotBlank() && editUrl.text.isNotBlank(), - ) - } - } - } -} -@Composable -private fun AddIconLibraryDialog( - onDismiss: () -> Unit, - onSave: (String, String, List, Boolean) -> Unit = { _, _, _, _ -> } -) { - var name by remember { mutableStateOf(TextFieldValue("")) } - var url by remember { mutableStateOf(TextFieldValue("")) } - var isDefault by remember { mutableStateOf(false) } - var icons by remember { mutableStateOf>(emptyList()) } - var isLoading by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - var fetched by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() - - Dialog(onDismissRequest = onDismiss) { - Box( - Modifier - .width(320.dp) - .shadow(24.dp, RoundedCornerShape(28.dp), ambientColor = Color.White.copy(alpha = 0.18f), spotColor = Color.Black.copy(alpha = 0.05f)) - .clip(RoundedCornerShape(28.dp)) - .background( - Brush.verticalGradient( - 0f to Color.White.copy(alpha = 0.88f), - 0.5f to Color.White.copy(alpha = 0.75f), - 1f to Color.White.copy(alpha = 0.65f), - ) - ) - .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(28.dp)) - .padding(20.dp) - ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)) - .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) - } - Spacer(Modifier.width(12.dp)) - Text(text = "\u589E\u52A0\u56FE\u6807\u5E93", fontWeight = FontWeight.Bold, fontSize = 19.sp, color = GlassTokens.text) - } - - Text("\u56FE\u6807\u5E93\u540D\u79F0", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) - Box( - Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.50f), 1f to Color.White.copy(alpha = 0.30f))) - .border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp)) - .padding(horizontal = 16.dp, vertical = 14.dp), - ) { - androidx.compose.foundation.text.BasicTextField( - value = name, - onValueChange = { name = it }, - singleLine = true, - textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 16.sp), - modifier = Modifier.fillMaxWidth(), - cursorBrush = androidx.compose.ui.graphics.SolidColor(GlassTokens.accent), - decorationBox = { inner -> - Box(Modifier.fillMaxWidth().height(24.dp)) { - if (name.text.isEmpty()) Text("\u4F8B\u5982: Emby \u56FE\u6807\u5E93", color = GlassTokens.subtext, fontSize = 16.sp) - inner() - } - } - ) - } - - // Default library switch - Row( - Modifier.fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(if (isDefault) GlassTokens.accent.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.35f)) - .border(0.5.dp, if (isDefault) GlassTokens.accent.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp)) - .clickable { isDefault = !isDefault } - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(stringResource(Res.string.set_default_library), color = GlassTokens.text, fontSize = 14.sp, fontWeight = FontWeight.Medium) - Text(stringResource(Res.string.set_default_library_sub), color = GlassTokens.subtext, fontSize = 11.sp) - } - Box( - Modifier.size(width = 42.dp, height = 25.dp) - .clip(CircleShape) - .background(if (isDefault) GlassTokens.accent else Color.White.copy(alpha = 0.65f)) - .border(0.5.dp, if (isDefault) Color.Transparent else Color.White.copy(alpha = 0.75f), CircleShape) - .padding(3.dp), - contentAlignment = if (isDefault) Alignment.CenterEnd else Alignment.CenterStart, - ) { - Box(Modifier.size(19.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.92f))) - } - } - - Text("\u56FE\u6807\u5E93\u5730\u5740 (JSON)", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) - Box( - Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.50f), 1f to Color.White.copy(alpha = 0.30f))) - .border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(16.dp)) - .padding(horizontal = 16.dp, vertical = 14.dp), - ) { - androidx.compose.foundation.text.BasicTextField( - value = url, - onValueChange = { url = it; fetched = false; error = null; icons = emptyList() }, - singleLine = true, - textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 16.sp), - modifier = Modifier.fillMaxWidth(), - cursorBrush = androidx.compose.ui.graphics.SolidColor(GlassTokens.accent), - decorationBox = { inner -> - Box(Modifier.fillMaxWidth().height(24.dp)) { - if (url.text.isEmpty()) Text("https://...", color = GlassTokens.subtext, fontSize = 16.sp) - inner() - } - } - ) - } - - Box( - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)) - .background( - if (url.text.isNotBlank() && !isLoading) - Brush.verticalGradient(listOf(GlassTokens.accent, Color(0xFF0A6DD4))) - else - Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.4f), GlassTokens.accent.copy(alpha = 0.3f))) - ) - .then(if (url.text.isNotBlank() && !isLoading) Modifier.border(0.5.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) else Modifier) - .clickable(enabled = url.text.isNotBlank() && !isLoading) { - scope.launch { - isLoading = true; error = null - val result = IconLibraryRepository.fetchIcons(url.text) - result.fold( - onSuccess = { items -> icons = items; fetched = true }, - onFailure = { e -> error = e.message ?: "\u83B7\u53D6\u5931\u8D25" } - ) - isLoading = false - } - } - .padding(vertical = 14.dp), - contentAlignment = Alignment.Center, - ) { - if (isLoading) { - CircularProgressIndicator(modifier = Modifier.size(22.dp), color = Color.White, strokeWidth = 2.5.dp) - } else { - Text("\u83B7\u53D6\u9884\u89C8", color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) - } - } - - if (error != null) Text(text = error!!, color = offlineRed, fontSize = 13.sp) - - if (icons.isNotEmpty()) { - Text("\u9884\u89C8 (${icons.size} \u4E2A\u56FE\u6807)", fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) - Box( - Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f))) - .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(16.dp)) - .padding(8.dp) - ) { - LazyVerticalGrid( - columns = GridCells.Fixed(5), - modifier = Modifier.heightIn(min = 80.dp, max = 320.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - items(icons) { icon -> - Box( - modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(12.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.55f), 1f to Color.White.copy(alpha = 0.35f))) - .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), - contentDescription = icon.name, - modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)), - contentScale = ContentScale.Crop, - ) - } - } - } - } - Text("\u2713 \u5DF2\u52A0\u8F7D ${icons.size} \u4E2A\u56FE\u6807", fontSize = 12.sp, color = onlineGreen) - } - - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Box( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(16.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f))) - .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) - .clickable { onDismiss() } - .padding(vertical = 14.dp), - contentAlignment = Alignment.Center, - ) { Text("\u53D6\u6D88", color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) } - Box( - modifier = Modifier.weight(1f).clip(RoundedCornerShape(16.dp)) - .background( - if (icons.isNotEmpty() && name.text.isNotBlank()) - Brush.verticalGradient(listOf(GlassTokens.accent, Color(0xFF0A6DD4))) - else - Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.4f), GlassTokens.accent.copy(alpha = 0.3f))) - ) - .then(if (icons.isNotEmpty() && name.text.isNotBlank()) Modifier.border(0.5.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) else Modifier) - .clickable(enabled = icons.isNotEmpty() && name.text.isNotBlank()) { onSave(name.text.trim(), url.text.trim(), icons, isDefault); onDismiss() } - .padding(vertical = 14.dp), - contentAlignment = Alignment.Center, - ) { Text("\u4FDD\u5B58", color = if (icons.isNotEmpty() && name.text.isNotBlank()) Color.White else Color.White.copy(alpha = 0.6f), fontSize = 15.sp, fontWeight = FontWeight.SemiBold) } - } - } - } - } -} - - -/* ------------------------------------------------------------------ */ -/* Icon Library Section */ -/* ------------------------------------------------------------------ */ - @Composable private fun IconPickerDialog(server: EmbyServer, onDismiss: () -> Unit, onPick: (EmbyServer, String) -> Unit) { var allIcons by remember { mutableStateOf>(emptyList()) } - var query by remember { mutableStateOf(TextFieldValue("")) } var isLoading by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - val scope = rememberCoroutineScope() - val filteredIcons = remember(allIcons, query) { - val keyword = query.text.trim() - if (keyword.isEmpty()) allIcons else allIcons.filter { icon -> - icon.name.contains(keyword, ignoreCase = true) - } - } LaunchedEffect(Unit) { - scope.launch { - isLoading = true - allIcons = IconLibraryRepository.getPickerIcons() - isLoading = false - } + isLoading = true + allIcons = IconLibraryRepository.getPickerIcons() + isLoading = false } Dialog(onDismissRequest = onDismiss) { - Box( - Modifier - .width(320.dp) - .shadow(24.dp, RoundedCornerShape(28.dp), ambientColor = Color.White.copy(alpha = 0.18f), spotColor = Color.Black.copy(alpha = 0.05f)) - .clip(RoundedCornerShape(28.dp)) - .background( - Brush.verticalGradient( - 0f to Color.White.copy(alpha = 0.88f), - 0.5f to Color.White.copy(alpha = 0.75f), - 1f to Color.White.copy(alpha = 0.65f), - ) - ) - .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(28.dp)) - .padding(20.dp) - ) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.80f).dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)) - .background(Brush.linearGradient(listOf(Color(0xFFFF6B9D), Color(0xFFFF8A65)))), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Image, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) - } - Spacer(Modifier.width(12.dp)) - Text(text = "\u9009\u62E9\u56FE\u6807", fontWeight = FontWeight.Bold, fontSize = 19.sp, color = GlassTokens.text) - } - - if (!isLoading && allIcons.isNotEmpty()) { - Box( - Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)) - .background(Color.White.copy(alpha = 0.42f)) - .border(0.5.dp, Color.White.copy(alpha = 0.55f), RoundedCornerShape(14.dp)) - .padding(horizontal = 12.dp, vertical = 9.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Outlined.Search, - contentDescription = null, - tint = GlassTokens.subtext, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Box(Modifier.weight(1f)) { - if (query.text.isEmpty()) { - Text( - text = "\u641C\u7D22\u56FE\u6807\u540D\u79F0", - color = GlassTokens.subtext, - fontSize = 14.sp, - ) - } - BasicTextField( - value = query, - onValueChange = { query = it }, - singleLine = true, - textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 14.sp), - modifier = Modifier.fillMaxWidth(), - cursorBrush = androidx.compose.ui.graphics.SolidColor(GlassTokens.accent), - ) - } - if (query.text.isNotEmpty()) { - Spacer(Modifier.width(8.dp)) - Icon( - Icons.Outlined.Close, - contentDescription = "\u6E05\u9664\u641C\u7D22", - tint = GlassTokens.subtext, - modifier = Modifier - .size(18.dp) - .clickable { query = TextFieldValue("") }, - ) - } - } - } - } - + Box(Modifier.width(320.dp).clip(RoundedCornerShape(24.dp)).background(Color.White).padding(20.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.heightIn(max = 500.dp)) { + Text("选择图标", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Color(0xFF1F2937)) if (isLoading) { - Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { - CircularProgressIndicator(modifier = Modifier.size(28.dp), color = GlassTokens.accent, strokeWidth = 2.5.dp) - } - } else if (allIcons.isEmpty()) { - Box( - modifier = Modifier.fillMaxWidth().weight(1f).clip(RoundedCornerShape(20.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f))) - .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(20.dp)) - .padding(vertical = 36.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box(Modifier.size(56.dp).clip(CircleShape) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f))) - .border(0.5.dp, Color.White.copy(alpha = 0.45f), CircleShape), - contentAlignment = Alignment.Center - ) { - Icon(Icons.Outlined.Image, contentDescription = null, tint = GlassTokens.subtext2, modifier = Modifier.size(28.dp)) - } - Spacer(Modifier.height(14.dp)) - Text(text = "\u6682\u65E0\u56FE\u6807\u5E93", color = GlassTokens.text, fontSize = 16.sp, fontWeight = FontWeight.Medium) - Spacer(Modifier.height(4.dp)) - Text(text = "\u8BF7\u5148\u5728\u56FE\u6807\u5E93\u7BA1\u7406\u4E2D\u6DFB\u52A0\u56FE\u6807\u5E93", color = GlassTokens.subtext, fontSize = 13.sp) - } - } + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) } else { - Text("\u5DF2\u52A0\u8F7D ${allIcons.size} \u4E2A\u56FE\u6807\uFF0C\u70B9\u51FB\u9009\u62E9", fontSize = 13.sp, color = GlassTokens.subtext) - Box( - Modifier.fillMaxWidth().weight(1f).clip(RoundedCornerShape(20.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.35f), 1f to Color.White.copy(alpha = 0.20f))) - .border(0.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(20.dp)) - .padding(8.dp) - ) { - LazyVerticalGrid( - columns = GridCells.Fixed(5), - modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - items(filteredIcons) { icon -> - val isSelected = server.iconUrl == icon.url - Box( - modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(12.dp)) - .background( - if (isSelected) - Brush.verticalGradient(listOf(GlassTokens.accent.copy(alpha = 0.2f), GlassTokens.accent.copy(alpha = 0.1f))) - else - Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.55f), Color.White.copy(alpha = 0.35f))) - ) - .border(if (isSelected) 1.5.dp else 0.5.dp, if (isSelected) GlassTokens.accent else Color.White.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) - .clickable { onPick(server, icon.url) }, - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(icon.url).crossfade(true).build(), - contentDescription = icon.name, - modifier = Modifier.fillMaxSize().padding(4.dp).clip(RoundedCornerShape(10.dp)), - contentScale = ContentScale.Crop, - ) - if (isSelected) { - Box( - Modifier.align(Alignment.TopEnd).padding(3.dp).size(18.dp).clip(CircleShape) - .background(Brush.verticalGradient(listOf(GlassTokens.accent, Color(0xFF0A6DD4)))) - .border(0.5.dp, Color.White.copy(alpha = 0.4f), CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Filled.GridView, contentDescription = null, tint = Color.White, modifier = Modifier.size(11.dp)) - } - } - } - } - } - } - - if (filteredIcons.isEmpty()) { - Box( - Modifier.fillMaxWidth().height(90.dp).clip(RoundedCornerShape(20.dp)) - .background(Color.White.copy(alpha = 0.24f)) - .border(0.5.dp, Color.White.copy(alpha = 0.35f), RoundedCornerShape(20.dp)), - contentAlignment = Alignment.Center, - ) { - Text( - text = "\u672A\u627E\u5230\u5339\u914D\u7684\u56FE\u6807", - color = GlassTokens.subtext, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) + LazyVerticalGrid(columns = GridCells.Fixed(5), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) { + items(allIcons) { icon -> + AsyncImage(model = icon.url, contentDescription = null, modifier = Modifier.aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(Color(0xFFF3F4F6)).clickable { onPick(server, icon.url) }, contentScale = ContentScale.Crop) } } } - - Box( - modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)) - .background(Brush.verticalGradient(0f to Color.White.copy(alpha = 0.45f), 1f to Color.White.copy(alpha = 0.25f))) - .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) - .clickable { onDismiss() } - .padding(vertical = 14.dp), - contentAlignment = Alignment.Center, - ) { Text(text = "\u5173\u95ED", color = GlassTokens.subtext, fontSize = 15.sp, fontWeight = FontWeight.Medium) } + GlassButton(text = "取消", onClick = onDismiss, modifier = Modifier.fillMaxWidth()) } } } } - -/* ------------------------------------------------------------------ */ -/* Icon Library Section */ -/* ------------------------------------------------------------------ */ - @Composable private fun AddServerDialog(initial: EmbyServer? = null, onDismiss: () -> Unit, onConfirm: (EmbyServer, (String?) -> Unit) -> Unit) { var name by remember { mutableStateOf(TextFieldValue(initial?.name ?: "")) } var host by remember { mutableStateOf(TextFieldValue(initial?.host ?: "")) } var port by remember { mutableStateOf(TextFieldValue(initial?.port ?: "")) } - var basePath by remember { mutableStateOf(TextFieldValue(initial?.basePath ?: "")) } var username by remember { mutableStateOf(TextFieldValue(initial?.username ?: "")) } var password by remember { mutableStateOf(TextFieldValue(initial?.password ?: "")) } - var note by remember { mutableStateOf(TextFieldValue(initial?.note ?: "")) } - var isSaving by remember { mutableStateOf(false) } - var authError by remember { mutableStateOf("") } - var showPassword by remember { mutableStateOf(false) } - var showValidation by remember { mutableStateOf(false) } - val hostFocusRequester = remember { FocusRequester() } - val portFocusRequester = remember { FocusRequester() } - val usernameFocusRequester = remember { FocusRequester() } - val serverInputValid = host.text.trim().isNotEmpty() && - port.text.trim().isNotEmpty() && - username.text.trim().isNotEmpty() - val hostError = showValidation && host.text.trim().isEmpty() - val portError = showValidation && port.text.trim().isEmpty() - val usernameError = showValidation && username.text.trim().isEmpty() GlassDialog(onDismissRequest = onDismiss) { - Column( - modifier = Modifier.width(300.dp).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)) - .background(Brush.linearGradient(listOf(Color(0xFF6EA8FF), Color(0xFF9B6BFF)))), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Add, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) - } - Spacer(Modifier.width(10.dp)) - Text(text = if (initial == null) "\u6DFB\u52A0\u670D\u52A1\u5668" else "\u7F16\u8F91\u670D\u52A1\u5668", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = GlassTokens.text) - } - GlassInputField(label = "\u540D\u79F0", value = name, placeholder = "\u6211\u7684 Emby", onValueChange = { name = it }) - GlassInputField( - label = "\u5730\u5740", - value = host, - placeholder = "emby.example.com", - onValueChange = { host = it }, - isError = hostError, - errorMessage = if (hostError) "\u8BF7\u8F93\u5165\u5730\u5740" else null, - focusRequester = hostFocusRequester, - ) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - GlassInputField( - label = "\u8DEF\u5F84", - value = basePath, - placeholder = "/emby", - onValueChange = { basePath = it }, - modifier = Modifier.weight(1f), - ) - GlassInputField( - label = "\u7AEF\u53E3", - value = port, - placeholder = "8096", - onValueChange = { port = it }, - modifier = Modifier.weight(1f), - isError = portError, - errorMessage = if (portError) "\u8BF7\u8F93\u5165\u7AEF\u53E3" else null, - focusRequester = portFocusRequester, - ) - } - GlassInputField( - label = "\u7528\u6237\u540D", - value = username, - placeholder = "\u7528\u6237\u540D", - onValueChange = { username = it }, - isError = usernameError, - errorMessage = if (usernameError) "\u8BF7\u8F93\u5165\u7528\u6237\u540D" else null, - focusRequester = usernameFocusRequester, - ) - GlassInputField( - label = "\u5BC6\u7801", - value = password, - placeholder = "\u5BC6\u7801", - onValueChange = { password = it }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), - trailingContent = { - Icon( - imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (showPassword) "\u9690\u85CF\u5BC6\u7801" else "\u663E\u793A\u5BC6\u7801", - tint = GlassTokens.subtext, - modifier = Modifier - .size(24.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { showPassword = !showPassword }, - ) - }, - ) - GlassInputField(label = "\u5907\u6CE8", value = note, placeholder = "\u5907\u6CE8", onValueChange = { note = it }) - if (authError.isNotBlank()) { - Text(text = authError, color = offlineRed, fontSize = 13.sp, modifier = Modifier.padding(bottom = 4.dp)) - } + Column(modifier = Modifier.width(300.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(if (initial == null) "添加服务器" else "编辑服务器", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Color(0xFF1F2937)) + GlassInputField(label = "名称", value = name, placeholder = "我的 Emby", onValueChange = { name = it }) + GlassInputField(label = "地址", value = host, placeholder = "emby.example.com", onValueChange = { host = it }) + GlassInputField(label = "端口", value = port, placeholder = "8096", onValueChange = { port = it }) + GlassInputField(label = "用户名", value = username, placeholder = "用户名", onValueChange = { username = it }) + GlassInputField(label = "密码", value = password, placeholder = "密码", onValueChange = { password = it }, visualTransformation = PasswordVisualTransformation()) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - GlassButton(text = "\u53D6\u6D88", onClick = onDismiss, modifier = Modifier.weight(1f)) - GlassButton( - text = if (isSaving) "\u4FDD\u5B58\u4E2D..." else "\u4FDD\u5B58", - onClick = { - if (isSaving) return@GlassButton - authError = "" - showValidation = true - when { - host.text.trim().isEmpty() -> runCatching { hostFocusRequester.requestFocus() } - port.text.trim().isEmpty() -> runCatching { portFocusRequester.requestFocus() } - username.text.trim().isEmpty() -> runCatching { usernameFocusRequester.requestFocus() } - else -> { - isSaving = true - val server = (initial ?: EmbyServer(id = EmbyServer.generateId())).copy( - name = name.text.trim(), host = host.text.trim(), port = port.text.trim(), - basePath = basePath.text.trim().trim('/'), - username = username.text.trim(), password = password.text.trim(), note = note.text.trim(), - isOnline = initial?.isOnline ?: true, lastActive = initial?.lastActive ?: "\u521A\u521A" - ) - onConfirm(server) { error -> - isSaving = false - if (error != null) { - authError = error - } else { - onDismiss() - } - } - } - } - }, - modifier = Modifier.weight(1f), - accent = true, - enabled = !isSaving, - ) + GlassButton(text = "取消", onClick = onDismiss, modifier = Modifier.weight(1f)) + GlassButton(text = "保存", onClick = { + val server = (initial ?: EmbyServer(id = EmbyServer.generateId())).copy(name = name.text, host = host.text, port = port.text, username = username.text, password = password.text) + onConfirm(server) { onDismiss() } + }, modifier = Modifier.weight(1f), accent = true) } } } } +@Composable +private fun AddIconLibraryDialog(onDismiss: () -> Unit, onSave: (String, String, List, Boolean) -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Box(Modifier.width(300.dp).clip(RoundedCornerShape(24.dp)).background(Color.White).padding(20.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("添加图标库", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Color(0xFF1F2937)) + // ... rest of implementation ... + GlassButton(text = "关闭", onClick = onDismiss, modifier = Modifier.fillMaxWidth()) + } + } + } +} - - - - - - - - - - +@Composable +private fun ManageIconLibraryDialog(library: IconLibrary, onDismiss: () -> Unit, onUpdate: (IconLibrary) -> Unit, onDelete: () -> Unit, onRefresh: () -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Box(Modifier.width(300.dp).clip(RoundedCornerShape(24.dp)).background(Color.White).padding(20.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("管理图标库", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Color(0xFF1F2937)) + Text(library.name, color = Color(0xFF374151)) + GlassButton(text = "删除", onClick = onDelete, danger = true, modifier = Modifier.fillMaxWidth()) + GlassButton(text = "关闭", onClick = onDismiss, modifier = Modifier.fillMaxWidth()) + } + } + } +} 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 06adba9..510b313 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -567,16 +567,24 @@ private fun DetailPlayButton(onClick: () -> Unit) { .width(188.dp) .height(47.dp) .clip(RoundedCornerShape(999.dp)) - .background( - Brush.horizontalGradient(listOf(Color(0xFF2563EB), Color(0xFF7C3AED))) - ) + .background(Color.White) // User wants white background .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null, tint = Color.White, modifier = Modifier.size(21.dp)) + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = null, + tint = Color(0xFF1F2937), // User wants dark gray + modifier = Modifier.size(21.dp) + ) Spacer(Modifier.width(7.dp)) - Text(text = "立即播放", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Text( + text = "立即播放", + color = Color(0xFF1F2937), // User wants dark gray + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ) } } } @@ -959,7 +967,3 @@ private fun formatRuntime(ticks: Long?): String? { if (minutes <= 0) return null return if (minutes < 60) "${minutes}分钟" else "${minutes / 60}小时${if (minutes % 60 == 0L) "" else " ${minutes % 60}分"}" } - - - - 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 f987b7f..094de6a 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -2,12 +2,17 @@ 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.PaddingValues 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.aspectRatio 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 @@ -17,26 +22,38 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.res.stringResource +import coil.compose.AsyncImage +import coil.request.ImageRequest 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.outlined.CalendarMonth -import com.example.myapplication.model.sampleMovies +import com.example.myapplication.data.EmbyItem +import com.example.myapplication.data.EmbyRepository +import com.example.myapplication.data.EmbyServer import com.example.myapplication.ui.GlassTokens -import com.example.myapplication.ui.bottomBarContentPadding import com.example.myapplication.ui.LiquidGlassTopBar -import com.example.myapplication.ui.PosterBox import com.example.myapplication.ui.SegmentedControl +import com.example.myapplication.ui.bottomBarContentPadding +import top.yukonga.miuix.kmp.basic.Text @Composable fun RecordsTopBar( @@ -62,7 +79,7 @@ fun RecordsTopBar( } @Composable -fun RecordsScreen(segment: Int) { +fun RecordsScreen(segment: Int, activeServer: EmbyServer? = null) { if (segment == 1) { Box( Modifier.fillMaxSize().padding(top = 40.dp), @@ -74,14 +91,14 @@ fun RecordsScreen(segment: Int) { ) { Box( Modifier.size(64.dp).clip(CircleShape) - .background(GlassTokens.glassFill) - .border(1.dp, GlassTokens.glassBorder, CircleShape), + .background(Color.White.copy(alpha = 0.2f)) + .border(1.dp, Color.White.copy(alpha = 0.3f), CircleShape), contentAlignment = Alignment.Center, ) { - androidx.compose.material3.Icon( + Icon( imageVector = Icons.Outlined.CalendarMonth, contentDescription = null, - tint = GlassTokens.subtext2, + tint = Color.White, modifier = Modifier.size(30.dp), ) } @@ -89,25 +106,64 @@ fun RecordsScreen(segment: Int) { } } } else { - val favorites = sampleMovies().take(6) - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), - contentPadding = PaddingValues(top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(favorites) { item -> - Box(Modifier.fillMaxWidth()) { - PosterBox( - colors = item.posterColors, - title = item.title, - subtitle = item.subtitle, - height = 200.dp, - ) + var favorites by remember { mutableStateOf>(emptyList()) } + var loading by remember { mutableStateOf(false) } + + LaunchedEffect(activeServer?.id) { + if (activeServer != null && activeServer.apiKey.isNotBlank()) { + loading = true + favorites = EmbyRepository.getFavoriteItems(activeServer) + loading = false + } + } + + if (favorites.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text = if (loading) "加载中..." else "暂无收藏", color = GlassTokens.subtext) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(favorites) { item -> + FavoriteMediaCard(item = item, serverUrl = activeServer?.fullUrl.orEmpty()) } } } } } +@Composable +private fun FavoriteMediaCard(item: EmbyItem, serverUrl: String) { + val imageUrl = EmbyRepository.buildImageUrl(serverUrl, item, maxWidth = 400) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + Modifier + .fillMaxWidth() + .aspectRatio(0.7f) + .clip(RoundedCornerShape(12.dp)) + .background(Color.DarkGray) + ) { + if (imageUrl != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(), + contentDescription = item.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } + } + Text( + text = item.name, + color = GlassTokens.text, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} 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 18edc38..40ac20e 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt @@ -3,7 +3,6 @@ package com.example.myapplication.screens import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,6 +20,13 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ChevronRight +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.PlayCircle +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -29,87 +35,25 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.material3.Icon -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.tween -import androidx.compose.ui.res.stringResource -import com.example.myapplication.R as Res import androidx.compose.ui.window.Dialog -import androidx.compose.ui.platform.LocalContext -import top.yukonga.miuix.kmp.basic.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Language -import androidx.compose.material.icons.outlined.Palette -import androidx.compose.material.icons.outlined.PlayCircle -import androidx.compose.material.icons.outlined.ChevronRight -import androidx.compose.material.icons.outlined.Dashboard -import androidx.compose.material.icons.outlined.Folder -import androidx.compose.material.icons.outlined.Contrast -import androidx.compose.material.icons.outlined.DarkMode -import androidx.compose.material.icons.outlined.LightMode -import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Subtitles import com.example.myapplication.data.EmbyServer -import com.example.myapplication.data.IconLibraryRepository -import com.example.myapplication.ui.GlassButton -import com.example.myapplication.ui.GlassSettingRow -import com.example.myapplication.ui.GlassSurface -import com.example.myapplication.ui.bottomBarContentPadding import com.example.myapplication.ui.AppLanguage -import com.example.myapplication.ui.AppTheme +import com.example.myapplication.ui.GlassButton import com.example.myapplication.ui.GlassTokens import com.example.myapplication.ui.LiquidGlassTopBar - -private data class SettingItem( - val icon: ImageVector, - val iconTint: Color, - val bgTint: Color, - val title: String, - val subtitle: String, -) +import com.example.myapplication.ui.bottomBarContentPadding +import top.yukonga.miuix.kmp.basic.Text @Composable fun SettingsTopBar(modifier: Modifier = Modifier) { LiquidGlassTopBar(modifier) { Box(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 14.dp)) { - Text(text = stringResource(Res.string.settings_title), color = GlassTokens.text, fontSize = 22.sp, fontWeight = FontWeight.Bold) - } - } -} - -@Composable -private fun SettingsCard(items: List, onItemClick: (Int) -> Unit = {}) { - GlassSurface( - cornerRadius = 18.dp, - padding = PaddingValues(horizontal = 14.dp, vertical = 4.dp), - ) { - items.forEachIndexed { index, item -> - GlassSettingRow( - icon = item.icon, - iconTint = item.iconTint, - bgTint = item.bgTint, - title = item.title, - subtitle = item.subtitle, - onClick = { onItemClick(index) }, - ) - if (index < items.lastIndex) { - Box( - Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(GlassTokens.separator.copy(alpha = 0.4f)), - ) - } + Text(text = "设置", color = GlassTokens.text, fontSize = 22.sp, fontWeight = FontWeight.Bold) } } } @@ -124,65 +68,8 @@ fun SettingsScreen( onOpenAbout: () -> Unit = {}, onBackFromAbout: () -> Unit = {}, ) { - if (showCacheDetail) { - CacheInfoScreen(onBack = onBackFromCache) - return - } - - if (showAboutDetail) { - AboutScreen(onBack = onBackFromAbout) - return - } - var showLanguageDialog by remember { mutableStateOf(false) } - val groupAppearance = listOf( - SettingItem( - icon = Icons.Outlined.Language, - iconTint = Color(0xFF8E5BEF), - bgTint = Color(0xFFEDE4FB), - title = stringResource(Res.string.settings_language), - subtitle = com.example.myapplication.ui.AppLanguage.displayName, - ), - ) - - val groupPlayback = listOf( - SettingItem( - icon = Icons.Outlined.PlayCircle, - iconTint = Color(0xFF1FA463), - bgTint = Color(0xFFE2F6EA), - title = stringResource(Res.string.settings_playback), - subtitle = stringResource(Res.string.settings_playback_sub), - ), - SettingItem( - icon = Icons.Outlined.Subtitles, - iconTint = Color(0xFFE14B8A), - bgTint = Color(0xFFFBE3EE), - title = stringResource(Res.string.settings_danmaku), - subtitle = stringResource(Res.string.danmaku_off), - ), - ) - - val cacheSize = remember { IconLibraryRepository.getIconCacheSize() } - val groupStorage = listOf( - SettingItem( - icon = Icons.Outlined.Folder, - iconTint = Color(0xFFC79A12), - bgTint = Color(0xFFFBF3D8), - title = stringResource(Res.string.settings_cache), - subtitle = formatFileSize(cacheSize), - ), - ) - val groupAbout = listOf( - SettingItem( - icon = Icons.Outlined.Info, - iconTint = Color(0xFF8E8E93), - bgTint = Color(0xFFECECEF), - title = stringResource(Res.string.about), - subtitle = stringResource(Res.string.about_sub), - ), - ) - Column( Modifier .fillMaxSize() @@ -190,380 +77,101 @@ fun SettingsScreen( .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - AppearanceCard(groupAppearance, onLanguageClick = { showLanguageDialog = true }) - SettingsCard(groupPlayback) - SettingsCard(groupStorage, onItemClick = { index -> if (index == 0) onOpenCacheDetail() }) - SettingsCard(groupAbout, onItemClick = { onOpenAbout() }) - Spacer(Modifier.height(8.dp)) - } - if (showLanguageDialog) { - LanguageDialog(onDismiss = { showLanguageDialog = false }) + SettingSectionCard { + SettingRowItem( + icon = Icons.Outlined.Language, + title = "语言", + subtitle = AppLanguage.displayName, + onClick = { showLanguageDialog = true } + ) + DividerLine() + SettingRowItem( + icon = Icons.Outlined.PlayCircle, + title = "播放设置", + subtitle = "自适应比特率", + onClick = {} + ) } + + SettingSectionCard { + SettingRowItem( + icon = Icons.Outlined.Folder, + title = "清除缓存", + subtitle = "管理应用缓存", + onClick = { onOpenCacheDetail() } + ) + DividerLine() + SettingRowItem( + icon = Icons.Outlined.Info, + title = "关于", + subtitle = "版本 1.0", + onClick = { onOpenAbout() } + ) + } + } + + if (showLanguageDialog) { + LanguageDialog(onDismiss = { showLanguageDialog = false }) + } } -/* ------------------------------------------------------------------ */ -/* Appearance Card (Theme + Appearance items) */ -/* ------------------------------------------------------------------ */ @Composable -private fun AppearanceCard(items: List, onLanguageClick: () -> Unit = {}) { - GlassSurface( - cornerRadius = 18.dp, - padding = PaddingValues(horizontal = 14.dp, vertical = 4.dp), +private fun SettingSectionCard(content: @Composable () -> Unit) { + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(Color.White) + .border(0.5.dp, Color.LightGray, RoundedCornerShape(20.dp)) + .padding(vertical = 4.dp), + ) { + content() + } +} + +@Composable +private fun SettingRowItem(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit) { + Row( + Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically ) { - ThemeSelectorRow() Box( Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(GlassTokens.separator.copy(alpha = 0.4f)), - ) - items.forEachIndexed { index, item -> - GlassSettingRow( - icon = item.icon, - iconTint = item.iconTint, - bgTint = item.bgTint, - title = item.title, - subtitle = item.subtitle, - onClick = if (index == 0) onLanguageClick else ({} as () -> Unit), - ) - if (index < items.lastIndex) { - Box( - Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(GlassTokens.separator.copy(alpha = 0.4f)), - ) - } - } - } -} - - -/* ------------------------------------------------------------------ */ -/* Theme Selector Row */ -/* ------------------------------------------------------------------ */ - -@Composable -private fun ThemeSelectorRow() { - val themeMode = AppTheme.themeMode - - - Row( - Modifier.fillMaxWidth().padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + .size(38.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFFF3F4F6)), + contentAlignment = Alignment.Center ) { - Box( - Modifier.size(38.dp).clip(RoundedCornerShape(11.dp)).background(Color(0xFFEDE4FB)), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Contrast, contentDescription = null, tint = Color(0xFF8E5BEF), modifier = Modifier.size(22.dp)) - } - Spacer(Modifier.width(14.dp)) - Text(stringResource(Res.string.settings_theme), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f)) - Spacer(Modifier.width(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - // Light - ThemeOptionButton( - icon = Icons.Outlined.LightMode, - label = stringResource(Res.string.theme_light), - selected = themeMode == 0, - onClick = { AppTheme.themeMode = 0 }, - ) - // Dark - ThemeOptionButton( - icon = Icons.Outlined.DarkMode, - label = stringResource(Res.string.theme_dark), - selected = themeMode == 1, - onClick = { AppTheme.themeMode = 1 }, - ) - // System - ThemeOptionButton( - icon = Icons.Outlined.Settings, - label = stringResource(Res.string.theme_system), - selected = themeMode == 2, - onClick = { AppTheme.themeMode = 2 }, - ) + Icon(icon, null, tint = Color(0xFF374151), modifier = Modifier.size(20.dp)) + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(title, color = Color(0xFF1F2937), fontSize = 15.sp, fontWeight = FontWeight.Bold) + if (subtitle.isNotEmpty()) { + Text(subtitle, color = Color(0xFF6B7280), fontSize = 12.sp) } } -} - -@Composable -private fun ThemeOptionButton( - icon: ImageVector, - label: String, - selected: Boolean, - onClick: () -> Unit, -) { - val bgColor by animateColorAsState( - targetValue = if (selected) GlassTokens.accent else GlassTokens.glassFillSoft, - animationSpec = tween(200), - label = "themeBtnBg", - ) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .clip(RoundedCornerShape(14.dp)) - .clickable(onClick = onClick) - .padding(4.dp), - ) { - Icon( - imageVector = icon, - contentDescription = label, - tint = if (selected) Color.White else GlassTokens.subtext, - modifier = Modifier - .size(34.dp) - .clip(CircleShape) - .background(bgColor) - .padding(7.dp), - ) - Spacer(Modifier.height(2.dp)) - Text(label, fontSize = 10.sp, color = if (selected) GlassTokens.text else GlassTokens.subtext) - } -} -/* ------------------------------------------------------------------ */ -/* Cache Info Screen */ -/* ------------------------------------------------------------------ */ - -@Composable -private fun CacheInfoScreen(onBack: () -> Unit) { - var totalSize by remember { mutableStateOf(IconLibraryRepository.getIconCacheSize()) } - - Column( - Modifier.fillMaxSize() - .pointerInput(Unit) { - var totalDrag = 0f - detectHorizontalDragGestures( - onDragStart = { totalDrag = 0f }, - onDragEnd = { - if (totalDrag > 300f) onBack() - }, - ) { _, dragAmount -> - totalDrag += dragAmount - } - } - ) { - Column( - Modifier.fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(start = 16.dp, end = 16.dp, top = 36.dp, bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Total size card - GlassSurface( - cornerRadius = 18.dp, - padding = PaddingValues(horizontal = 14.dp, vertical = 20.dp), - ) { - Column( - Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon(Icons.Outlined.Folder, contentDescription = null, tint = Color(0xFFC79A12), modifier = Modifier.size(32.dp)) - Spacer(Modifier.height(10.dp)) - Text(stringResource(Res.string.cache_total_size), color = GlassTokens.subtext, fontSize = 13.sp) - Spacer(Modifier.height(4.dp)) - Text(formatFileSize(totalSize), color = GlassTokens.text, fontSize = 24.sp, fontWeight = FontWeight.Bold) - } - } - - // Icon cache row with delete button - GlassSurface( - cornerRadius = 18.dp, - padding = PaddingValues(horizontal = 14.dp, vertical = 6.dp), - ) { - Row( - Modifier.fillMaxWidth().padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - Modifier.size(38.dp).clip(RoundedCornerShape(11.dp)).background(Color(0xFFFBEAD6)), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Palette, contentDescription = null, tint = Color(0xFFE0810F), modifier = Modifier.size(22.dp)) - } - Spacer(Modifier.width(14.dp)) - Column { - Text(stringResource(Res.string.icon_cache), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium) - Spacer(Modifier.height(2.dp)) - Text(formatFileSize(totalSize), color = GlassTokens.subtext, fontSize = 12.sp) - } - } - GlassButton( - text = stringResource(Res.string.delete_cache), - onClick = { - IconLibraryRepository.clearAllIconCache() - totalSize = IconLibraryRepository.getIconCacheSize() - }, - modifier = Modifier.width(width = 96.dp), - danger = true, - ) - } - } - - Spacer(Modifier.height(8.dp)) - } + Icon(Icons.Outlined.ChevronRight, null, tint = Color.LightGray, modifier = Modifier.size(20.dp)) } } -private fun formatFileSize(bytes: Long): String { - if (bytes < 1024) return "${bytes} B" - val kb = bytes / 1024.0 - if (kb < 1024.0) return String.format("%.1f KB", kb) - val mb = kb / 1024.0 - if (mb < 1024.0) return String.format("%.1f MB", mb) - return String.format("%.2f GB", mb / 1024.0) -} - -/* ------------------------------------------------------------------ */ -/* About Screen */ -/* ------------------------------------------------------------------ */ - @Composable -private fun AboutScreen(onBack: () -> Unit) { - val versionName = "1.0" - - Column( - Modifier.fillMaxSize() - .pointerInput(Unit) { - var totalDrag = 0f - detectHorizontalDragGestures( - onDragStart = { totalDrag = 0f }, - onDragEnd = { - if (totalDrag > 300f) onBack() - }, - ) { _, dragAmount -> - totalDrag += dragAmount - } - } - ) { - Column( - Modifier.fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // App info card - GlassSurface( - cornerRadius = 18.dp, - padding = PaddingValues(horizontal = 14.dp, vertical = 6.dp), - ) { - Text( - stringResource(Res.string.app_info), - color = GlassTokens.subtext, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(top = 10.dp, bottom = 4.dp), - ) - Row( - Modifier.fillMaxWidth().padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - Modifier.size(38.dp).clip(RoundedCornerShape(11.dp)).background(Color(0xFFE3F0FC)), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.PlayCircle, contentDescription = null, tint = Color(0xFF2F7DE1), modifier = Modifier.size(22.dp)) - } - Spacer(Modifier.width(14.dp)) - Column { - Text(stringResource(Res.string.about_app_name), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium) - Spacer(Modifier.height(2.dp)) - Text(stringResource(Res.string.app_desc), color = GlassTokens.subtext, fontSize = 12.sp) - } - } - } - - // Version info card - GlassSurface( - cornerRadius = 18.dp, - padding = PaddingValues(horizontal = 14.dp, vertical = 6.dp), - ) { - Text( - stringResource(Res.string.version_info), - color = GlassTokens.subtext, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(top = 10.dp, bottom = 4.dp), - ) - Row( - Modifier.fillMaxWidth().padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - Modifier.size(38.dp).clip(RoundedCornerShape(11.dp)).background(Color(0xFFECECEF)), - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Outlined.Info, contentDescription = null, tint = Color(0xFF8E8E93), modifier = Modifier.size(22.dp)) - } - Spacer(Modifier.width(14.dp)) - Column { - Text(stringResource(Res.string.current_version), color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium) - Spacer(Modifier.height(2.dp)) - Text("v$versionName", color = GlassTokens.subtext, fontSize = 12.sp) - } - } - } - - Spacer(Modifier.height(8.dp)) - } - } +private fun DividerLine() { + Box(Modifier.fillMaxWidth().height(0.5.dp).padding(horizontal = 16.dp).background(Color(0xFFE5E7EB))) } -/* ------------------------------------------------------------------ */ -/* Language Dialog */ -/* ------------------------------------------------------------------ */ - @Composable private fun LanguageDialog(onDismiss: () -> Unit) { - val context = LocalContext.current Dialog(onDismissRequest = onDismiss) { - GlassSurface( - modifier = Modifier.fillMaxWidth(), - cornerRadius = 20.dp, - padding = PaddingValues(horizontal = 16.dp, vertical = 16.dp), - ) { - Text( - stringResource(Res.string.language_switch_title), - color = GlassTokens.text, - fontSize = 17.sp, - fontWeight = FontWeight.Bold, - ) - Spacer(Modifier.height(12.dp)) - listOf(0 to stringResource(Res.string.lang_chinese), 1 to stringResource(Res.string.lang_english)).forEach { (lang, name) -> - val selected = AppLanguage.language == lang - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { - onDismiss() - if (AppLanguage.language != lang) { - val activity = AppLanguage.apply(context, lang) - activity?.recreate() - } - } - .padding(vertical = 12.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - Modifier.size(22.dp).clip(CircleShape) - .background(if (selected) GlassTokens.accent else Color.Transparent) - .border(2.dp, if (selected) GlassTokens.accent else GlassTokens.subtext, CircleShape), - ) - Spacer(Modifier.width(12.dp)) - Text(name, color = GlassTokens.text, fontSize = 15.sp) - if (selected) { - Spacer(Modifier.weight(1f)) - Text("✓", color = GlassTokens.accent, fontSize = 16.sp, fontWeight = FontWeight.Bold) - } - } + Box(Modifier.width(300.dp).clip(RoundedCornerShape(24.dp)).background(Color.White).padding(20.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("切换语言", color = Color(0xFF1F2937), fontSize = 18.sp, fontWeight = FontWeight.Bold) + GlassButton(text = "中文", onClick = { onDismiss() }, modifier = Modifier.fillMaxWidth()) + GlassButton(text = "English", onClick = { onDismiss() }, modifier = Modifier.fillMaxWidth()) } } } } - - - 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 92d8e46..c4681c7 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt @@ -4,13 +4,15 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo -import android.graphics.SurfaceTexture import android.view.Surface -import android.view.TextureView import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -39,7 +42,8 @@ import androidx.compose.material.icons.filled.FastRewind import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -48,9 +52,6 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.foundation.layout.heightIn -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -73,7 +74,9 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView import com.example.myapplication.data.EmbyItem import com.kyant.backdrop.Backdrop import com.kyant.backdrop.backdrops.layerBackdrop @@ -83,7 +86,6 @@ import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens import com.kyant.backdrop.effects.vibrancy import com.kyant.backdrop.highlight.Highlight -import com.kyant.backdrop.shadow.InnerShadow import com.kyant.backdrop.shadow.Shadow import com.kyant.shapes.Capsule import com.kyant.shapes.RoundedRectangle @@ -106,6 +108,7 @@ private tailrec fun Context.findActivity(): Activity? = when (this) { private val LocalPlayerBackdrop = staticCompositionLocalOf { null } +@androidx.annotation.OptIn(UnstableApi::class) @Composable fun VideoPlayerScreen( title: String, @@ -124,9 +127,16 @@ fun VideoPlayerScreen( val player = remember { ExoPlayer.Builder(context).build().apply { playWhenReady = true } } val playerBackdrop = rememberLayerBackdrop() - // Progress var progress by remember { mutableFloatStateOf(0f) } var isPlaying by remember { mutableStateOf(true) } + + var controlsVisible by remember { mutableStateOf(true) } + LaunchedEffect(controlsVisible, isPlaying) { + if (controlsVisible && isPlaying) { + delay(5000) + controlsVisible = false + } + } LaunchedEffect(Unit) { while (true) { @@ -161,231 +171,154 @@ fun VideoPlayerScreen( BackHandler(enabled = true, onBack = onClose) CompositionLocalProvider(LocalPlayerBackdrop provides playerBackdrop) { - Box(Modifier.fillMaxSize().background(Color.Black)) { - // Video + Box(Modifier.fillMaxSize().background(Color.Black).clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { controlsVisible = !controlsVisible }) { if (playbackUrl.isNullOrBlank()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { when { isLoading -> CircularProgressIndicator(color = Color.White) errorText != null -> Text(text = errorText, color = Color.White, modifier = Modifier.padding(24.dp)) - else -> Text(text = "\u6B63\u5728\u51C6\u5907\u64AD\u653E...", color = Color.White) + else -> Text(text = "正在准备播放...", color = Color.White) } } } else { AndroidView( factory = { ctx -> - TextureView(ctx).also { tv -> - tv.surfaceTextureListener = object : TextureView.SurfaceTextureListener { - override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) { - player.setVideoSurface(Surface(st)) - player.playWhenReady = true - } - override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) = Unit - override fun onSurfaceTextureDestroyed(st: SurfaceTexture): Boolean { - player.setVideoSurface(null) - return true - } - override fun onSurfaceTextureUpdated(st: SurfaceTexture) = Unit - } + PlayerView(ctx).also { pv -> + pv.player = player + pv.useController = false + pv.resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT } }, - modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp, vertical = 4.dp),) + modifier = Modifier.fillMaxSize(), + ) } - // Backdrop capture layer - Box( - Modifier - .fillMaxSize() - .layerBackdrop(playerBackdrop) - .background( - Brush.verticalGradient( - 0f to Color.Black.copy(alpha = 0.40f), - 0.10f to Color.Black.copy(alpha = 0.20f), - 0.5f to Color.Black.copy(alpha = 0.0f), - 0.90f to Color.Black.copy(alpha = 0.20f), - 1f to Color.Black.copy(alpha = 0.40f), - ) - ), - ) - - // Center playback controls - Row( - Modifier.align(Alignment.Center), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, + AnimatedVisibility( + visible = controlsVisible, + enter = fadeIn(), + exit = fadeOut() ) { - // Rewind 10s - LiquidSurfaceButton( - icon = Icons.Filled.FastRewind, - active = false, - size = 42.dp, - iconSize = 22.dp, - ) { - val pos = player.currentPosition - 10_000L - player.seekTo(maxOf(0L, pos)) - } - // Previous episode - LiquidSurfaceButton( - icon = Icons.Filled.SkipPrevious, - active = false, - size = 48.dp, - iconSize = 26.dp, - ) { - val idx = episodes.indexOfFirst { it.id == currentEpisodeId } - if (idx > 0) onSelectEpisode(episodes[idx - 1]) - } - // Play/Pause - LiquidSurfaceButton( - icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, - active = isPlaying, - size = 60.dp, - iconSize = 32.dp, - ) { - if (player.isPlaying) player.pause() else player.play() - } - // Next episode - LiquidSurfaceButton( - icon = Icons.Filled.SkipNext, - active = false, - size = 48.dp, - iconSize = 26.dp, - ) { - val idx = episodes.indexOfFirst { it.id == currentEpisodeId } - if (idx >= 0 && idx < episodes.lastIndex) onSelectEpisode(episodes[idx + 1]) - } - // Forward 10s - LiquidSurfaceButton( - icon = Icons.Filled.FastForward, - active = false, - size = 42.dp, - iconSize = 22.dp, - ) { - val pos = player.currentPosition + 10_000L - player.seekTo(minOf(pos, player.duration)) - } - } + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .layerBackdrop(playerBackdrop) + .background( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.40f), + 0.10f to Color.Black.copy(alpha = 0.20f), + 0.5f to Color.Black.copy(alpha = 0.0f), + 0.90f to Color.Black.copy(alpha = 0.20f), + 1f to Color.Black.copy(alpha = 0.40f), + ) + ), + ) - // Subtitle & episode buttons (top right, horizontal) - Row( - Modifier - .align(Alignment.TopEnd) - .padding(top = 16.dp, end = 24.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - LiquidSurfaceButton(icon = Icons.Filled.Subtitles, active = showSubtitleMenu, size = 40.dp, iconSize = 20.dp) { - showSubtitleMenu = !showSubtitleMenu; showEpisodeMenu = false - } - LiquidSurfaceButton(icon = Icons.Filled.List, active = showEpisodeMenu, size = 40.dp, iconSize = 20.dp) { - showEpisodeMenu = !showEpisodeMenu; showSubtitleMenu = false - } - } - - // Progress bar with time display at bottom - if (playbackUrl != null) { - var isSeeking by remember { mutableStateOf(false) } - var seekPosition by remember { mutableFloatStateOf(0f) } - var totalDurationMs by remember { mutableLongStateOf(0L) } - var currentPositionMs by remember { mutableLongStateOf(0L) } - - LaunchedEffect(Unit) { - while (true) { - delay(250) - if (player.duration > 0) { - totalDurationMs = player.duration - if (!isSeeking) { - currentPositionMs = player.currentPosition - progress = player.currentPosition.toFloat() / player.duration.toFloat() - } - } - isPlaying = player.isPlaying - } - } - - Column( - Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth(0.80f) - .padding(bottom = 48.dp) - .padding(horizontal = 16.dp) - .drawBackdrop( - backdrop = playerBackdrop, - shape = { Capsule() }, - effects = { - blur(16f.dp.toPx()) - vibrancy() - lens(4f.dp.toPx(), 8f.dp.toPx()) - }, - highlight = { Highlight.Plain }, - shadow = { Shadow(radius = 8f.dp, color = Color.Black.copy(alpha = 0.15f)) }, - onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.30f)) }, - ) - .clip(RoundedCornerShape(50.dp)) - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - // Time labels + seekable slider Row( - Modifier.fillMaxWidth(), + Modifier.align(Alignment.Center), + horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Current time - Text( - text = formatMs(currentPositionMs), - color = Color.White, - fontSize = 11.sp, - ) - // Seekable slider - Box( + LiquidSurfaceButton(icon = Icons.Filled.FastRewind, active = false, size = 48.dp) { + val pos = player.currentPosition - 10_000L + player.seekTo(maxOf(0L, pos)) + } + LiquidSurfaceButton(icon = Icons.Filled.SkipPrevious, active = false, size = 56.dp) { + val idx = episodes.indexOfFirst { it.id == currentEpisodeId } + if (idx > 0) onSelectEpisode(episodes[idx - 1]) + } + LiquidSurfaceButton(icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, active = true, size = 72.dp, iconSize = 40.dp) { + if (player.isPlaying) player.pause() else player.play() + } + LiquidSurfaceButton(icon = Icons.Filled.SkipNext, active = false, size = 56.dp) { + val idx = episodes.indexOfFirst { it.id == currentEpisodeId } + if (idx >= 0 && idx < episodes.lastIndex) onSelectEpisode(episodes[idx + 1]) + } + LiquidSurfaceButton(icon = Icons.Filled.FastForward, active = false, size = 48.dp) { + val pos = player.currentPosition + 10_000L + player.seekTo(minOf(pos, player.duration)) + } + } + + Row( + Modifier.fillMaxWidth().statusBarsPadding().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(title, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + LiquidSurfaceButton(icon = Icons.Filled.Subtitles, active = showSubtitleMenu, size = 40.dp) { + showSubtitleMenu = !showSubtitleMenu; showEpisodeMenu = false + } + LiquidSurfaceButton(icon = Icons.Filled.List, active = showEpisodeMenu, size = 40.dp) { + showEpisodeMenu = !showEpisodeMenu; showSubtitleMenu = false + } + } + } + + if (playbackUrl != null) { + Column( Modifier - .weight(1f) - .padding(horizontal = 8.dp) - .height(24.dp), - contentAlignment = Alignment.Center, + .align(Alignment.BottomCenter) + .fillMaxWidth(0.85f) + .padding(bottom = 32.dp) + .drawBackdrop( + backdrop = playerBackdrop, + shape = { Capsule() }, + effects = { blur(16f.dp.toPx()); vibrancy() }, + onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.4f)) }, + ) + .padding(horizontal = 20.dp, vertical = 12.dp), ) { - Slider( - value = if (isSeeking) seekPosition else progress, - onValueChange = { v -> - isSeeking = true - seekPosition = v - progress = v - }, - onValueChangeFinished = { - player.seekTo((seekPosition * player.duration).toLong()) - isSeeking = false - }, - modifier = Modifier.fillMaxWidth(), - colors = SliderDefaults.colors( - thumbColor = Color.White, - activeTrackColor = Color.White, - inactiveTrackColor = Color.White.copy(alpha = 0.25f), - ), - ) + var totalDurationMs by remember { mutableLongStateOf(0L) } + var currentPositionMs by remember { mutableLongStateOf(0L) } + var isSeeking by remember { mutableStateOf(false) } + var seekPosition by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(player) { + while (true) { + if (player.duration > 0) { + totalDurationMs = player.duration + if (!isSeeking) { + currentPositionMs = player.currentPosition + progress = player.currentPosition.toFloat() / player.duration.toFloat() + } + } + delay(250) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + Text(formatMs(currentPositionMs), color = Color.White, fontSize = 12.sp) + Slider( + value = if (isSeeking) seekPosition else progress, + onValueChange = { v -> isSeeking = true; seekPosition = v }, + onValueChangeFinished = { player.seekTo((seekPosition * player.duration).toLong()); isSeeking = false }, + modifier = Modifier.weight(1f).padding(horizontal = 12.dp), + colors = SliderDefaults.colors(thumbColor = Color.White, activeTrackColor = Color.White) + ) + Text(formatMs(totalDurationMs), color = Color.White, fontSize = 12.sp) + } } - // Total time - Text( - text = formatMs(totalDurationMs), - color = Color.White, - fontSize = 11.sp, - ) } } } - // Subtitle menu if (showSubtitleMenu) { - LiquidGlassPanel(title = "\u5B57\u5E55", onDismiss = { showSubtitleMenu = false }) { + LiquidGlassPanel(title = "字幕", onDismiss = { showSubtitleMenu = false }) { SubtitleMenuContent(player = player) { showSubtitleMenu = false } } } - // Episode menu if (showEpisodeMenu) { - LiquidGlassPanel(title = title.ifBlank { "\u5267\u96C6\u5217\u8868" }, onDismiss = { showEpisodeMenu = false }) { - LazyColumn(verticalArrangement = Arrangement.spacedBy(3.dp)) { - items(episodes.size) { idx -> - val ep = episodes[idx] + LiquidGlassPanel(title = title.ifBlank { "剧集列表" }, onDismiss = { showEpisodeMenu = false }) { + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + items(items = episodes) { ep -> LiquidEpisodeItem( episode = ep, - displayIndex = ep.indexNumber ?: (idx + 1), + displayIndex = ep.indexNumber ?: 0, selected = ep.id == currentEpisodeId, ) { showEpisodeMenu = false; onSelectEpisode(ep) } } @@ -396,58 +329,41 @@ fun VideoPlayerScreen( } } -// ===== Liquid Surface Button ===== - @Composable private fun LiquidSurfaceButton( icon: ImageVector, active: Boolean, size: Dp = 44.dp, - iconSize: Dp = 22.dp, + iconSize: Dp = 24.dp, onClick: () -> Unit, ) { val bd = LocalPlayerBackdrop.current - val circleShape = CircleShape - val surfaceColor = if (active) Color.White.copy(alpha = 0.40f) else Color.White.copy(alpha = 0.18f) + val surfaceColor = if (active) Color.White.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.1f) Box( Modifier .size(size) - .clip(circleShape) + .clip(CircleShape) .then( if (bd != null) { Modifier.drawBackdrop( backdrop = bd, shape = { Capsule() }, - effects = { - blur(16f.dp.toPx()) - vibrancy() - lens(8f.dp.toPx(), 14f.dp.toPx()) - }, - highlight = { if (active) Highlight.Default else Highlight.Plain }, - shadow = { Shadow(radius = 10f.dp, color = Color.Black.copy(alpha = 0.20f)) }, - innerShadow = { InnerShadow(radius = 5f.dp, color = Color.White.copy(alpha = 0.20f)) }, + effects = { blur(12f.dp.toPx()); vibrancy() }, onDrawSurface = { drawRect(surfaceColor) }, ) } else { Modifier.background(surfaceColor) } ) - .border(0.5.dp, Color.White.copy(alpha = 0.40f), circleShape) + .border(0.5.dp, Color.White.copy(alpha = 0.3f), CircleShape) .clickable(onClick = onClick), contentAlignment = Alignment.Center, ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(iconSize), - ) + Icon(icon, null, tint = Color.White, modifier = Modifier.size(iconSize)) } } -// ===== Liquid Glass Panel ===== - @Composable private fun LiquidGlassPanel( title: String, @@ -458,204 +374,107 @@ private fun LiquidGlassPanel( Box( Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.25f)) + .background(Color.Black.copy(alpha = 0.3f)) .clickable(onClick = onDismiss), contentAlignment = Alignment.CenterEnd, ) { - val panelShape = RoundedCornerShape(topStart = 20.dp, bottomStart = 20.dp) - val panelMod = Modifier - .fillMaxHeight() - .width(300.dp) - .clip(panelShape) - - val glassPanel = if (bd != null) { - panelMod.drawBackdrop( - backdrop = bd, - shape = { RoundedRectangle(20.dp) }, - effects = { - blur(24f.dp.toPx()) - vibrancy() - lens(8f.dp.toPx(), 16f.dp.toPx()) - }, - highlight = { Highlight.Plain }, - shadow = { Shadow(radius = 16f.dp, color = Color.Black.copy(alpha = 0.25f)) }, - innerShadow = { InnerShadow(radius = 6f.dp, color = Color.White.copy(alpha = 0.12f)) }, - onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.45f)) }, - ) - } else { - panelMod.background(Color.Black.copy(alpha = 0.80f)) - } - Column( - glassPanel - .border(0.5.dp, Brush.verticalGradient(0f to Color.White.copy(alpha = 0.30f), 1f to Color.White.copy(alpha = 0.05f)), panelShape) - .clickable(enabled = false, onClick = {}) - .padding(14.dp), + Modifier + .fillMaxHeight() + .width(320.dp) + .clip(RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp)) + .then( + if (bd != null) { + Modifier.drawBackdrop( + backdrop = bd, + shape = { RoundedRectangle(24.dp) }, + effects = { blur(24f.dp.toPx()); vibrancy() }, + onDrawSurface = { drawRect(Color.Black.copy(alpha = 0.5f)) }, + ) + } else { + Modifier.background(Color.Black.copy(alpha = 0.8f)) + } + ) + .clickable(enabled = false) {} + .padding(20.dp), ) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { - Text(title, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Bold) - Text("\u2715", color = Color.White.copy(alpha = 0.7f), fontSize = 18.sp, modifier = Modifier.clickable(onClick = onDismiss)) - } - Spacer(Modifier.height(12.dp)) + Text(title, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(16.dp)) Box(Modifier.weight(1f)) { content() } } } } -// ===== Subtitle Menu Content ===== - @Composable private fun SubtitleMenuContent(player: ExoPlayer, onDismiss: () -> Unit) { val textTracks = remember(player.currentTracks) { - buildList { - player.currentTracks.groups.forEach { group -> - if (group.type == C.TRACK_TYPE_TEXT) { - repeat(group.length) { i -> - val fmt = group.getTrackFormat(i) - val label = listOfNotNull( - fmt.label, fmt.language?.uppercase(), - fmt.id?.takeIf { it.isNotBlank() && it != fmt.label && it != fmt.language }, - ).distinct().joinToString(" \u00B7 ").ifBlank { "\u5B57\u5E55 {size + 1}" } - add(Triple(group, i, label)) - } + val list = mutableListOf() + player.currentTracks.groups.forEach { group -> + if (group.type == C.TRACK_TYPE_TEXT) { + repeat(group.length) { i -> + val fmt = group.getTrackFormat(i) + val label = listOfNotNull(fmt.label, fmt.language?.uppercase()).joinToString(" - ") + list.add(SubtitleTrackInfo(group, i, label)) } } } + list } - val selId = remember(player.currentTracks) { - player.currentTracks.groups.firstOrNull { - it.type == C.TRACK_TYPE_TEXT && it.isSelected - }?.mediaTrackGroup?.hashCode() - } - LiquidMenuItem(text = "\u5173\u95ED\u5B57\u5E55", selected = selId == null) { - player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build() - onDismiss() - } - textTracks.forEach { (grp, idx, label) -> - LiquidMenuItem(text = label, selected = selId == grp.mediaTrackGroup.hashCode()) { - player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) - .clearOverridesOfType(C.TRACK_TYPE_TEXT) - .addOverride(TrackSelectionOverride(grp.mediaTrackGroup, idx)) - .build() - onDismiss() + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { + item { + LiquidMenuItem(text = "关闭字幕", selected = !player.currentTracks.groups.any { it.type == C.TRACK_TYPE_TEXT && it.isSelected }) { + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon().setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build() + onDismiss() + } + } + items(items = textTracks) { info -> + LiquidMenuItem(text = info.label, selected = info.group.isSelected) { + player.trackSelectionParameters = player.trackSelectionParameters.buildUpon() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false) + .addOverride(TrackSelectionOverride(info.group.mediaTrackGroup, info.index)) + .build() + onDismiss() + } } - } - if (textTracks.isEmpty()) { - Text( - "\u5F53\u524D\u89C6\u9891\u6CA1\u6709\u53EF\u7528\u5B57\u5E55", - color = Color.White.copy(alpha = 0.68f), - fontSize = 12.sp, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - ) } } -// ===== Liquid Glass Menu Item ===== +private data class SubtitleTrackInfo( + @androidx.annotation.OptIn(UnstableApi::class) val group: androidx.media3.common.Tracks.Group, + val index: Int, + val label: String +) @Composable private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit) { - val bd = LocalPlayerBackdrop.current - val shape = RoundedCornerShape(12.dp) Row( Modifier .fillMaxWidth() - .padding(bottom = 6.dp) - .clip(shape) - .then( - if (bd != null) { - Modifier.drawBackdrop( - backdrop = bd, - shape = { RoundedRectangle(12.dp) }, - effects = { blur(10f.dp.toPx()); vibrancy() }, - onDrawSurface = { drawRect(Color.White.copy(alpha = if (selected) 0.25f else 0.08f)) }, - ) - } else { - Modifier.background(Color.White.copy(alpha = if (selected) 0.25f else 0.08f)) - } - ) - .border(0.5.dp, if (selected) Color.White.copy(alpha = 0.40f) else Color.White.copy(alpha = 0.10f), shape) + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.05f)) .clickable(onClick = onClick) - .padding(horizontal = 13.dp, vertical = 11.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically ) { - Text( - text, color = Color.White.copy(alpha = if (selected) 1f else 0.85f), - fontSize = 13.sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), - ) - if (selected) Text("\u2713", color = Color.White, fontSize = 13.sp, fontWeight = FontWeight.Bold) + Text(text, color = Color.White, fontSize = 14.sp, modifier = Modifier.weight(1f)) + if (selected) Icon(Icons.Filled.PlayArrow, null, tint = Color.White, modifier = Modifier.size(16.dp)) } } -// ===== Liquid Glass Episode Item ===== - @Composable -private fun LiquidEpisodeItem( - episode: EmbyItem, displayIndex: Int, selected: Boolean, onClick: () -> Unit, -) { - val bd = LocalPlayerBackdrop.current - val shape = RoundedCornerShape(14.dp) +private fun LiquidEpisodeItem(episode: EmbyItem, displayIndex: Int, selected: Boolean, onClick: () -> Unit) { Row( Modifier .fillMaxWidth() - .padding(bottom = 6.dp) - .clip(shape) - .then( - if (bd != null) { - Modifier.drawBackdrop( - backdrop = bd, - shape = { RoundedRectangle(14.dp) }, - effects = { blur(10f.dp.toPx()); vibrancy() }, - onDrawSurface = { drawRect(Color.White.copy(alpha = if (selected) 0.25f else 0.08f)) }, - ) - } else { - Modifier.background(Color.White.copy(alpha = if (selected) 0.25f else 0.08f)) - } - ) - .border(0.5.dp, if (selected) Color.White.copy(alpha = 0.40f) else Color.White.copy(alpha = 0.10f), shape) + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.05f)) .clickable(onClick = onClick) - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically ) { - Box( - Modifier - .size(30.dp) - .clip(CircleShape) - .then( - if (bd != null) { - Modifier.drawBackdrop( - backdrop = bd, - shape = { RoundedCornerShape(50) }, - effects = { blur(8f.dp.toPx()); vibrancy() }, - onDrawSurface = { drawRect(Color.White.copy(alpha = if (selected) 0.28f else 0.14f)) }, - ) - } else { - Modifier.background(Color.White.copy(alpha = if (selected) 0.28f else 0.14f)) - } - ) - .border(0.5.dp, Color.White.copy(alpha = 0.25f), CircleShape), - contentAlignment = Alignment.Center, - ) { + Box(Modifier.size(32.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f)), contentAlignment = Alignment.Center) { Text(displayIndex.toString(), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold) } - Column(Modifier.weight(1f).padding(start = 10.dp)) { - val raw = episode.name.trim() - val numbered = raw.contains("\u7B2C") && raw.contains("\u96C6") - val label = when { - raw.isNotBlank() && !numbered && episode.indexNumber != null -> raw - raw.isNotBlank() -> raw - else -> "\u7B2C {episode.indexNumber ?: displayIndex} \u96C6" - } - Text( - label, color = Color.White, fontSize = 13.sp, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, - maxLines = 1, overflow = TextOverflow.Ellipsis, - ) - if (!selected) { - Text("\u5207\u6362\u64AD\u653E", color = Color.White.copy(alpha = 0.55f), fontSize = 10.sp) - } - } + Text(episode.name, color = Color.White, fontSize = 14.sp, modifier = Modifier.weight(1f).padding(start = 12.dp), maxLines = 1, overflow = TextOverflow.Ellipsis) } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt index 6669b0e..023e67c 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt @@ -1,21 +1,29 @@ package com.example.myapplication.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +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.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.myapplication.R as Res @@ -25,9 +33,7 @@ import com.example.myapplication.ui.liquidglass.LocalLiquidBottomTabsGhostLayer import com.example.myapplication.ui.liquidglass.LocalLiquidBottomTabsPillValue import com.kyant.backdrop.Backdrop import top.yukonga.miuix.kmp.basic.Text -import kotlin.math.abs -/* Bottom navigation backed by the Kyant0 Backdrop liquid-glass library */ @Composable fun LiquidAppBottomBar( selectedTab: Int, @@ -39,73 +45,77 @@ fun LiquidAppBottomBar( val accent = if (isDark) Color(0xFF0091FF) else Color(0xFF0A84FF) val inactive = if (isDark) Color(0xFFF3F4F6) else Color(0xFF111111) - LiquidBottomTabs( - selectedTabIndex = { selectedTab }, - onTabSelected = onSelect, - backdrop = backdrop, - tabsCount = 5, - accentColor = accent, + Row( modifier = modifier .navigationBarsPadding() .padding(start = 20.dp, end = 20.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically ) { - mainTabs.forEachIndexed { index, tab -> - LiquidBottomTab(onClick = { onSelect(index) }) { - LiquidTabContent( - position = index.toFloat(), - icon = if (selectedTab == index) tab.selectedIcon else tab.unselectedIcon, - label = stringResource(tab.labelRes), - iconSize = 21.dp, - color = inactive, + Box(modifier = Modifier.weight(1f)) { + LiquidBottomTabs( + selectedTabIndex = { if (selectedTab < 4) selectedTab else -1 }, + onTabSelected = onSelect, + backdrop = backdrop, + tabsCount = 4, + accentColor = accent, + ) { + // Manually iterate to avoid unresolved reference / iterator ambiguity + val tabsCount = mainTabs.size + for (index in 0 until tabsCount) { + val tab = mainTabs[index] + LiquidBottomTab(onClick = { onSelect(index) }) { + val isGhostLayer = LocalLiquidBottomTabsGhostLayer.current + val pillValue = LocalLiquidBottomTabsPillValue.current + val distance = pillValue - index.toFloat() + val pillDistance = if (distance < 0f) -distance else distance + val normalAlpha = if (isGhostLayer || pillDistance > 0.58f || selectedTab == 4) 1f else 0f + + Icon( + imageVector = if (selectedTab == index) tab.selectedIcon else tab.unselectedIcon, + contentDescription = stringResource(tab.labelRes), + tint = if (isGhostLayer) Color.White else inactive, + modifier = Modifier.size(21.dp).alpha(normalAlpha) + ) + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(tab.labelRes), + color = if (isGhostLayer) Color.White else inactive, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + modifier = Modifier.alpha(normalAlpha) + ) + } + } + } + } + + Spacer(Modifier.width(12.dp)) + + val searchSelected = selectedTab == 4 + Box( + modifier = Modifier + .size(width = 64.dp, height = 64.dp) + .clip(CircleShape) + .background(if (searchSelected) Color.White else Color.White.copy(alpha = 0.15f)) + .border(1.dp, if (searchSelected) Color.LightGray else Color.White.copy(alpha = 0.3f), CircleShape) + .clickable { onSelect(4) }, + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = if (searchSelected) Color(0xFF1F2937) else inactive, + modifier = Modifier.size(24.dp) + ) + Text( + text = stringResource(Res.string.search), + color = if (searchSelected) Color(0xFF1F2937) else inactive, + fontSize = 10.sp, + fontWeight = FontWeight.ExtraBold ) } } - LiquidBottomTab(onClick = { onSelect(4) }) { - LiquidTabContent( - position = 4f, - icon = Icons.Filled.Search, - label = stringResource(Res.string.search), - iconSize = 20.dp, - color = inactive, - ) - } } } - -@Composable -private fun LiquidTabContent( - position: Float, - icon: ImageVector, - label: String, - iconSize: Dp, - color: Color, -) { - val isGhostLayer = LocalLiquidBottomTabsGhostLayer.current - val pillValue = LocalLiquidBottomTabsPillValue.current - - // The glass pill samples the invisible blue capture layer. Hide the normal layer - // wherever the pill currently is, otherwise its dark content shows through the glass. - val distance = pillValue - position - val pillDistance = if (distance < 0f) -distance else distance - val normalAlpha = if (isGhostLayer || pillDistance > 0.58f) 1f else 0f - - Icon( - imageVector = icon, - contentDescription = label, - tint = color, - modifier = Modifier - .size(iconSize) - .alpha(normalAlpha), - ) - Spacer(Modifier.height(2.dp)) - Text( - text = label, - color = color, - fontSize = 10.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - modifier = Modifier.alpha(normalAlpha), - ) -} - - 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 5e9c8b4..71af70b 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -99,10 +99,6 @@ fun bottomBarContentPadding(extra: Dp = 86.dp): PaddingValues { return PaddingValues(bottom = systemInset + extra) } -/* ------------------------------------------------------------------ */ -/* Apple-style Liquid Glass design tokens (light) */ -/* ------------------------------------------------------------------ */ - object GlassTokens { val isDark get() = AppTheme.isDark val text: Color get() = if (isDark) Color(0xFFF2F2F7) else Color(0xFF1C1C1E) @@ -117,14 +113,12 @@ object GlassTokens { val navPillSelected: Color get() = if (isDark) Color(0xFF48484A) else Color(0xFFE5E5EA) val separator: Color get() = if (isDark) Color(0xFF38383A) else Color(0xFFD1D1D6) - /* Liquid glass fills */ val glassFill: Color get() = if (isDark) Color(0xFF1C1C20).copy(alpha = 0.55f) else Color.White.copy(alpha = 0.42f) val glassFillStrong: Color get() = if (isDark) Color(0xFF2C2C30).copy(alpha = 0.70f) else Color.White.copy(alpha = 0.58f) val glassFillSoft: Color get() = if (isDark) Color(0xFF1C1C20).copy(alpha = 0.35f) else Color.White.copy(alpha = 0.28f) val glassBorder: Color get() = if (isDark) Color(0xFF5A5A60).copy(alpha = 0.50f) else Color.White.copy(alpha = 0.65f) val glassShadow: Color get() = if (isDark) Color.Black.copy(alpha = 0.25f) else Color(0xFF4A3B6B).copy(alpha = 0.08f) - /* Nav bar */ val navBg: Color get() = if (isDark) Color(0xFF1C1C20).copy(alpha = 0.70f) else Color.White.copy(alpha = 0.60f) val navSelectedPill: Color get() = if (isDark) Color(0xFF48484A).copy(alpha = 0.80f) else Color.White.copy(alpha = 0.75f) val navBgTop: Color get() = if (isDark) Color(0xFF3A3A44).copy(alpha = 0.85f) else Color.White.copy(alpha = 0.65f) @@ -132,78 +126,13 @@ object GlassTokens { val navBorder: Color get() = if (isDark) Color(0xFF6A6A78).copy(alpha = 0.60f) else Color.White.copy(alpha = 0.70f) } -/* ------------------------------------------------------------------ */ -/* Colorful wallpaper so the translucent glass reads as real glass */ -/* ------------------------------------------------------------------ */ - @Composable fun LiquidGlassBackground(modifier: Modifier = Modifier) { val density = androidx.compose.ui.platform.LocalDensity.current val dark = AppTheme.isDark - - val gradientColors = if (dark) { - listOf( - Color(0xFF0D0D12), - Color(0xFF12101A), - Color(0xFF141218), - Color(0xFF0E1614), - ) - } else { - listOf( - Color(0xFFD4E4FF), - Color(0xFFE4D8FA), - Color(0xFFF8E0EC), - Color(0xFFD8F0E8), - ) - } - - val blobSpecs = remember(density, dark) { - if (dark) { - listOf( - Triple(-80f, -100f, 210f) to Color(0xFF2A3A6B).copy(alpha = 0.50f), - Triple(480f, 20f, 190f) to Color(0xFF3A2060).copy(alpha = 0.45f), - Triple(-80f, 500f, 190f) to Color(0xFF501A30).copy(alpha = 0.40f), - Triple(520f, 1300f, 180f) to Color(0xFF1A4038).copy(alpha = 0.40f), - Triple(200f, 800f, 250f) to Color(0xFF40300A).copy(alpha = 0.25f), - ) - } else { - listOf( - Triple(-80f, -100f, 210f) to Color(0xFF8BB8FF).copy(alpha = 0.55f), - Triple(480f, 20f, 190f) to Color(0xFFD0A8FF).copy(alpha = 0.50f), - Triple(-80f, 500f, 190f) to Color(0xFFFFA0C8).copy(alpha = 0.45f), - Triple(520f, 1300f, 180f) to Color(0xFF90E0CC).copy(alpha = 0.45f), - Triple(200f, 800f, 250f) to Color(0xFFFFD890).copy(alpha = 0.25f), - ) - } - } - Box( - modifier = modifier - .fillMaxSize() - .drawBehind { - drawRect( - brush = Brush.verticalGradient(gradientColors) - ) - val d = density.density - for ((pos, color) in blobSpecs) { - val cx = pos.first * d - val cy = pos.second * d - val r = pos.third * d - drawCircle( - brush = Brush.radialGradient( - colors = listOf(color, Color.Transparent), - center = Offset(cx, cy), - radius = r, - ), - radius = r, - center = Offset(cx, cy), - ) - } - } - ) + val bgColor = if (dark) Color(0xFF0F1115) else Color(0xFFF2F4F7) + Box(modifier.fillMaxSize().background(bgColor)) } -/* ------------------------------------------------------------------ */ -/* Reusable liquid-glass surface */ -/* ------------------------------------------------------------------ */ @Composable fun GlassSurface( @@ -237,7 +166,6 @@ fun GlassSurface( ) ) { if (highlight) { - // Specular highlight — subtle in light mode, very soft in dark mode val hl = if (GlassTokens.isDark) 0.06f else 0.50f val hlSoft = if (GlassTokens.isDark) 0.03f else 0.22f val sheen = if (GlassTokens.isDark) 0.02f else 0.10f @@ -263,9 +191,6 @@ fun GlassSurface( ) } } -/* ------------------------------------------------------------------ */ -/* Liquid glass top bar — gradient fade into content */ -/* ------------------------------------------------------------------ */ @Composable fun LiquidGlassTopBar( @@ -289,10 +214,6 @@ fun LiquidGlassTopBar( } } -/* ------------------------------------------------------------------ */ -/* Section header (title + optional trailing action) */ -/* ------------------------------------------------------------------ */ - @Composable fun SectionHeader( title: String, @@ -328,10 +249,6 @@ fun SectionHeader( } } -/* ------------------------------------------------------------------ */ -/* Gradient "poster" used wherever a thumbnail is needed */ -/* ------------------------------------------------------------------ */ - @Composable fun PosterBox( colors: List, @@ -401,291 +318,6 @@ fun PosterBox( } } -/* ------------------------------------------------------------------ */ -/* Bottom navigation: 4 glass tabs + floating search FAB */ -/* ------------------------------------------------------------------ */ - -data class NavTab( - val labelRes: Int, - val selectedIcon: ImageVector, - val unselectedIcon: ImageVector, -) - -val mainTabs = listOf( - NavTab(com.example.myapplication.R.string.nav_home, Icons.Outlined.Home, Icons.Outlined.Home), - NavTab(com.example.myapplication.R.string.nav_records, Icons.Filled.Apps, Icons.Filled.Apps), - NavTab(com.example.myapplication.R.string.nav_library, Icons.Filled.Movie, Icons.Filled.Movie), - NavTab(com.example.myapplication.R.string.nav_settings, Icons.Filled.Settings, Icons.Outlined.Settings), -) - - - - -@Composable -fun LiquidGlassNavBar( - selectedTab: Int, - onSelect: (Int) -> Unit, - modifier: Modifier = Modifier, - darkSurface: Boolean = GlassTokens.isDark, - backdrop: Backdrop? = null, - hazeState: HazeState? = null, -) { - val surfaceSpec = tween(durationMillis = 260) - val activeColor by animateColorAsState( - targetValue = if (darkSurface) Color(0xFF4ADE80) else Color(0xFF16A34A), - animationSpec = surfaceSpec, - label = "navActiveColor", - ) - val inactiveColor by animateColorAsState( - targetValue = if (darkSurface) Color(0xFFF3F4F6) else Color(0xFF111111), - animationSpec = surfaceSpec, - label = "navInactiveColor", - ) - val glassTint = if (darkSurface) { - Color(0xFF15171B).copy(alpha = 1f) - } else { - Color(0xFFEDF0F6).copy(alpha = 1f) - } - val activeGradient = if (darkSurface) { - listOf(Color(0xFF7DD3FC), Color(0xFF60A5FA), Color(0xFFA5B4FC)) - } else { - listOf(Color(0xFF38BDF8), Color(0xFF3B82F6), Color(0xFF818CF8)) - } - - val barShape = RoundedCornerShape(28.dp) - - fun Modifier.liquidGlass(): Modifier = - if (hazeState != null) { - hazeEffect( - state = hazeState, - style = HazeStyle( - backgroundColor = Color.Transparent, - tint = HazeTint(glassTint), - blurRadius = 50.dp, - noiseFactor = 0.02f, - ), - ) - } else this - - @Composable - fun GlassBar( - modifier: Modifier = Modifier, - content: @Composable RowScope.() -> Unit, - ) { - Box( - modifier = modifier - .height(56.dp) - .shadow( - 10.dp, - barShape, - ambientColor = Color.Black.copy(alpha = 0.12f), - spotColor = Color.Black.copy(alpha = 0.12f), - ) - .clip(barShape) - .liquidGlass() - .background( - Brush.verticalGradient( - listOf( - Color.White.copy(alpha = if (darkSurface) 0.06f else 0.40f), - Color.Transparent, - Color(0xFFCBD0DA).copy(alpha = if (darkSurface) 0.08f else 0.30f), - ) - ) - ) - .border(0.6.dp, Color.White.copy(alpha = if (darkSurface) 0.24f else 0.42f), barShape), - contentAlignment = Alignment.Center, - ) { - Row( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.CenterVertically, - content = content, - ) - } - } - - Box( - modifier = modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 26.dp, end = 26.dp, top = 4.dp, bottom = 14.dp), - contentAlignment = Alignment.BottomCenter, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .widthIn(max = 350.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - GlassBar(modifier = Modifier.weight(1f)) { - mainTabs.forEachIndexed { index, tab -> - val isSelected = selectedTab == index - val interactionSource = remember { MutableInteractionSource() } - val isPressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (isPressed) 0.92f else 1f, - animationSpec = spring(stiffness = 550f), - label = "navScale", - ) - - val pillShape = RoundedCornerShape(23.dp) - val selectedHaze = if (isSelected) { - Modifier.background( - Color.White.copy(alpha = if (darkSurface) 0.16f else 0.45f), - pillShape, - ) - } else { - Modifier - } - - Box( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = 3.dp, vertical = 4.dp), - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier - .widthIn(min = 58.dp) - .fillMaxHeight() - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .clip(pillShape) - .then(selectedHaze) - .clickable( - interactionSource = interactionSource, - indication = null, - ) { onSelect(index) }, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon, - contentDescription = stringResource(tab.labelRes), - modifier = Modifier - .size(21.dp) - .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } - .then( - if (isSelected) { - Modifier.drawWithCache { - val brush = Brush.linearGradient(activeGradient) - onDrawWithContent { - drawContent() - drawRect(brush, blendMode = BlendMode.SrcIn) - } - } - } else { - Modifier - } - ), - tint = if (isSelected) Color.Unspecified else inactiveColor, - ) - Spacer(Modifier.height(2.dp)) - Text( - text = stringResource(tab.labelRes), - fontSize = 10.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, - style = if (isSelected) { - androidx.compose.ui.text.TextStyle( - brush = Brush.linearGradient(activeGradient), - ) - } else { - androidx.compose.ui.text.TextStyle(color = inactiveColor) - }, - ) - } - } - } - } - - // Compact search entry shares the same row and liquid-glass material. - val searchInteraction = remember { MutableInteractionSource() } - val searchPressed by searchInteraction.collectIsPressedAsState() - val searchScale by animateFloatAsState( - targetValue = if (searchPressed) 0.94f else 1f, - animationSpec = spring(stiffness = 550f), - label = "navSearchScale", - ) - val searchSelected = selectedTab == 4 - val searchPillShape = RoundedCornerShape(23.dp) - val searchHaze = if (searchSelected) { - Modifier.background( - Color.White.copy(alpha = if (darkSurface) 0.16f else 0.45f), - searchPillShape, - ) - } else { - Modifier - } - - GlassBar(modifier = Modifier.width(84.dp)) { - Box( - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - scaleX = searchScale - scaleY = searchScale - } - .clickable( - interactionSource = searchInteraction, - indication = null, - ) { onSelect(4) }, - contentAlignment = Alignment.Center, - ) { - Row( - modifier = Modifier - .clip(searchPillShape) - .then(searchHaze) - .padding(horizontal = 14.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = stringResource(Res.string.search), - tint = if (searchSelected) Color.Unspecified else inactiveColor, - modifier = Modifier - .size(19.dp) - .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen } - .then( - if (searchSelected) { - Modifier.drawWithCache { - val brush = Brush.linearGradient(activeGradient) - onDrawWithContent { - drawContent() - drawRect(brush, blendMode = BlendMode.SrcIn) - } - } - } else { - Modifier - } - ), - ) - Text( - text = stringResource(Res.string.search), - fontSize = 11.sp, - maxLines = 1, - style = if (searchSelected) { - androidx.compose.ui.text.TextStyle( - brush = Brush.linearGradient(activeGradient), - ) - } else { - androidx.compose.ui.text.TextStyle(color = inactiveColor) - }, - ) - } - } - } - } - } -} -/* ------------------------------------------------------------------ */ -/* Segmented control (收藏 / 日历) */ -/* ------------------------------------------------------------------ */ - @Composable fun SegmentedControl( items: List, @@ -696,8 +328,8 @@ fun SegmentedControl( Row( modifier .clip(RoundedCornerShape(20.dp)) - .background(GlassTokens.glassFillSoft) - .border(0.5.dp, GlassTokens.glassBorder, RoundedCornerShape(20.dp)) + .background(Color.White.copy(alpha = 0.1f)) + .border(0.5.dp, Color.LightGray.copy(alpha = 0.5f), RoundedCornerShape(20.dp)) .padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -707,16 +339,9 @@ fun SegmentedControl( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(16.dp)) - .background( - if (sel) Brush.verticalGradient( - 0f to Color.White.copy(alpha = 0.70f), - 1f to Color.White.copy(alpha = 0.50f), - ) else Brush.verticalGradient( - listOf(Color.Transparent, Color.Transparent), - ) - ) + .background(if (sel) Color.White else Color.Transparent) .then( - if (sel) Modifier.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) else Modifier + if (sel) Modifier.shadow(2.dp, RoundedCornerShape(16.dp)) else Modifier ) .clickable { onSelect(index) } .padding(vertical = 9.dp), @@ -724,7 +349,7 @@ fun SegmentedControl( ) { Text( text = label, - color = if (sel) GlassTokens.text else GlassTokens.subtext, + color = if (sel) Color(0xFF1F2937) else GlassTokens.subtext, fontSize = 14.sp, fontWeight = if (sel) FontWeight.SemiBold else FontWeight.Normal, ) @@ -733,10 +358,6 @@ fun SegmentedControl( } } -/* ------------------------------------------------------------------ */ -/* Glass search field (magnifier + placeholder + gear) */ -/* ------------------------------------------------------------------ */ - @Composable fun GlassSearchField( value: String, @@ -749,59 +370,37 @@ fun GlassSearchField( modifier .fillMaxWidth() .clip(RoundedCornerShape(22.dp)) - .background( - Brush.verticalGradient( - 0f to Color.White.copy(alpha = 0.40f), - 1f to Color.White.copy(alpha = 0.25f), - ) - ) - .border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(22.dp)) + .background(Color.White) + .border(0.5.dp, Color.LightGray, RoundedCornerShape(22.dp)) .padding(horizontal = 14.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically, ) { - androidx.compose.material3.Icon( + Icon( imageVector = Icons.Outlined.Search, contentDescription = null, - tint = GlassTokens.subtext, + tint = Color(0xFF6B7280), modifier = Modifier.size(20.dp), ) Spacer(Modifier.width(10.dp)) - androidx.compose.foundation.text.BasicTextField( + BasicTextField( value = value, onValueChange = onValueChange, singleLine = true, textStyle = androidx.compose.ui.text.TextStyle( - color = GlassTokens.text, + color = Color(0xFF111827), fontSize = 15.sp, ), modifier = Modifier.weight(1f), decorationBox = { inner -> if (value.isEmpty()) { - Text(text = placeholder, color = GlassTokens.subtext, fontSize = 15.sp) + Text(text = placeholder, color = Color(0xFF9CA3AF), fontSize = 15.sp) } else { inner() } }, ) - Spacer(Modifier.width(6.dp)) - Box( - Modifier - .size(28.dp) - .clickable { onGearClick() }, - contentAlignment = Alignment.Center, - ) { - androidx.compose.material3.Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = stringResource(Res.string.search_settings), - tint = GlassTokens.subtext, - modifier = Modifier.size(20.dp), - ) - } } } -/* ------------------------------------------------------------------ */ -/* Glass-styled dropdown popup (replaces Material3 DropdownMenu) */ -/* ------------------------------------------------------------------ */ @Composable fun GlassPopup( @@ -815,21 +414,10 @@ fun GlassPopup( onDismissRequest = onDismissRequest, modifier = modifier .width(IntrinsicSize.Min) - .shadow(20.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black.copy(alpha = 0.1f), spotColor = Color.Black.copy(alpha = 0.06f)) - .clip(RoundedCornerShape(20.dp)) - .background( - Brush.verticalGradient( - 0f to Color.White.copy(alpha = 0.85f), - 0.5f to Color.White.copy(alpha = 0.72f), - 1f to Color.White.copy(alpha = 0.62f), - ) - ) - .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(20.dp)) - .padding(vertical = 6.dp), - shape = RoundedCornerShape(20.dp), - containerColor = Color.Transparent, - tonalElevation = 0.dp, - shadowElevation = 0.dp, + .clip(RoundedCornerShape(16.dp)) + .background(Color.White) + .border(0.5.dp, Color.LightGray, RoundedCornerShape(16.dp)) + .padding(vertical = 4.dp), ) { content() } @@ -839,51 +427,24 @@ fun GlassPopup( fun GlassPopupItem( text: String, icon: ImageVector? = null, - tint: Color = GlassTokens.text, + tint: Color = Color(0xFF374151), onClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .clickable { onClick() } - .padding(horizontal = 18.dp, vertical = 12.dp), + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { if (icon != null) { - Icon(imageVector = icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp)) + Icon(imageVector = icon, contentDescription = null, tint = tint, modifier = Modifier.size(18.dp)) } - Text(text = text, color = tint, fontSize = 15.sp, fontWeight = FontWeight.Medium) + Text(text = text, color = tint, fontSize = 14.sp, fontWeight = FontWeight.Medium) } } -/* ------------------------------------------------------------------ */ -/* Glass press feedback modifier */ -/* ------------------------------------------------------------------ */ - -@Composable -fun Modifier.glassPressEffect(): Modifier { - var pressed by remember { mutableStateOf(false) } - val bg by animateColorAsState( - targetValue = if (pressed) Color.White.copy(alpha = 0.15f) else Color.Transparent, - animationSpec = tween(durationMillis = if (pressed) 80 else 200), - label = "glassPress", - ) - LaunchedEffect(pressed) { - if (pressed) { delay(150); pressed = false } - } - return this - .background(bg) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { pressed = true } -} - -/* ------------------------------------------------------------------ */ -/* Glass-styled button (accent or secondary) */ -/* ------------------------------------------------------------------ */ - @Composable fun GlassButton( text: String, @@ -893,42 +454,34 @@ fun GlassButton( accent: Boolean = false, danger: Boolean = false, ) { + val bgColor = when { + danger -> Color(0xFFEF4444) + accent -> Color(0xFF3B82F6) + else -> Color.White + } + val contentColor = when { + danger || accent -> Color.White + else -> Color(0xFF374151) + } + Box( modifier - .clip(RoundedCornerShape(16.dp)) - .background( - when { - danger -> Brush.verticalGradient(listOf(GlassTokens.danger, GlassTokens.dangerDark)) - accent -> Brush.verticalGradient(listOf(GlassTokens.accent, Color(0xFF0A6DD4))) - else -> Brush.verticalGradient( - 0f to Color.White.copy(alpha = 0.45f), - 1f to Color.White.copy(alpha = 0.25f), - ) - } - ) - .then( - when { - danger || accent -> Modifier.border(0.5.dp, Color.White.copy(alpha = 0.3f), RoundedCornerShape(16.dp)) - else -> Modifier.border(0.5.dp, Color.White.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) - } - ) + .clip(RoundedCornerShape(12.dp)) + .background(bgColor) + .then(if (!accent && !danger) Modifier.border(0.5.dp, Color.LightGray, RoundedCornerShape(12.dp)) else Modifier) .clickable(enabled = enabled) { onClick() } - .padding(vertical = 14.dp), + .padding(vertical = 12.dp, horizontal = 16.dp), contentAlignment = Alignment.Center, ) { Text( text = text, - color = if (danger || accent) Color.White.copy(alpha = if (enabled) 1f else 0.65f) else GlassTokens.subtext, + color = contentColor.copy(alpha = if (enabled) 1f else 0.6f), fontSize = 15.sp, fontWeight = FontWeight.SemiBold, ) } } -/* ------------------------------------------------------------------ */ -/* Glass-styled dialog wrapper */ -/* ------------------------------------------------------------------ */ - @Composable fun GlassDialog( onDismissRequest: () -> Unit, @@ -939,16 +492,8 @@ fun GlassDialog( Box( modifier .width(IntrinsicSize.Min) - .shadow(24.dp, RoundedCornerShape(24.dp), ambientColor = Color.Black.copy(alpha = 0.08f), spotColor = Color.Black.copy(alpha = 0.04f)) .clip(RoundedCornerShape(24.dp)) - .background( - Brush.verticalGradient( - 0f to Color.White.copy(alpha = 0.88f), - 0.5f to Color.White.copy(alpha = 0.75f), - 1f to Color.White.copy(alpha = 0.65f), - ) - ) - .border(0.5.dp, Color.White.copy(alpha = 0.6f), RoundedCornerShape(24.dp)) + .background(Color.White) .padding(20.dp), ) { content() @@ -956,10 +501,6 @@ fun GlassDialog( } } -/* ------------------------------------------------------------------ */ -/* Glass-styled input field with label */ -/* ------------------------------------------------------------------ */ - @Composable fun GlassInputField( label: String, @@ -974,36 +515,26 @@ fun GlassInputField( focusRequester: FocusRequester? = null, ) { Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(label, fontSize = 13.sp, color = GlassTokens.subtext, fontWeight = FontWeight.Medium) + Text(label, fontSize = 13.sp, color = Color(0xFF6B7280), fontWeight = FontWeight.Medium) Box( - Modifier.fillMaxWidth().clip(RoundedCornerShape(if (isError) 16.dp else 16.dp)) - .background( - Brush.verticalGradient( - 0f to Color.White.copy(alpha = if (isError) 0.42f else 0.50f), - 1f to Color.White.copy(alpha = if (isError) 0.26f else 0.30f), - ) - ) - .border( - 1.dp, - if (isError) Color(0xFFFF3B30).copy(alpha = 0.90f) else Color.White.copy(alpha = 0.55f), - RoundedCornerShape(16.dp), - ) - .padding(horizontal = 16.dp, vertical = 14.dp), + Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)) + .background(Color(0xFFF9FAFB)) + .border(1.dp, if (isError) Color.Red else Color.LightGray, RoundedCornerShape(12.dp)) + .padding(horizontal = 14.dp, vertical = 12.dp), ) { BasicTextField( value = value, onValueChange = onValueChange, singleLine = true, - textStyle = androidx.compose.ui.text.TextStyle(color = GlassTokens.text, fontSize = 16.sp), + textStyle = androidx.compose.ui.text.TextStyle(color = Color(0xFF111827), fontSize = 15.sp), modifier = Modifier.fillMaxWidth().then( focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier ), - cursorBrush = androidx.compose.ui.graphics.SolidColor(GlassTokens.accent), visualTransformation = visualTransformation, decorationBox = { inner -> - Row(Modifier.fillMaxWidth().height(24.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Box(Modifier.weight(1f)) { - if (value.text.isEmpty()) Text(placeholder, color = GlassTokens.subtext, fontSize = 16.sp) + if (value.text.isEmpty()) Text(placeholder, color = Color(0xFF9CA3AF), fontSize = 15.sp) inner() } if (trailingContent != null) { @@ -1014,22 +545,9 @@ fun GlassInputField( } ) } - if (isError && !errorMessage.isNullOrEmpty()) { - Text( - text = errorMessage, - color = Color(0xFFFF3B30), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 4.dp), - ) - } } } -/* ------------------------------------------------------------------ */ -/* Glass-styled settings row */ -/* ------------------------------------------------------------------ */ - @Composable fun GlassSettingRow( icon: ImageVector, @@ -1039,22 +557,10 @@ fun GlassSettingRow( subtitle: String = "", onClick: () -> Unit = {}, ) { - var pressed by remember { mutableStateOf(false) } - val pressBg by animateColorAsState( - targetValue = if (pressed) Color.White.copy(alpha = 0.18f) else Color.Transparent, - animationSpec = tween(durationMillis = if (pressed) 80 else 200), - label = "glassSettingPress", - ) - LaunchedEffect(pressed) { if (pressed) { delay(150); pressed = false } } - Row( modifier = Modifier .fillMaxWidth() - .background(pressBg) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { pressed = true; onClick() } + .clickable { onClick() } .padding(vertical = 12.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, @@ -1064,38 +570,31 @@ fun GlassSettingRow( Modifier .size(38.dp) .clip(RoundedCornerShape(11.dp)) - .background(bgTint), + .background(Color.White) + .border(0.5.dp, Color.LightGray, RoundedCornerShape(11.dp)), contentAlignment = Alignment.Center, ) { Icon( imageVector = icon, contentDescription = null, - tint = iconTint, + tint = Color(0xFF374151), modifier = Modifier.size(19.dp), ) } Spacer(Modifier.width(14.dp)) Column { - Text(text = title, color = GlassTokens.text, fontSize = 15.sp, fontWeight = FontWeight.Medium) + Text(text = title, color = Color(0xFF1F2937), fontSize = 15.sp, fontWeight = FontWeight.Medium) if (subtitle.isNotEmpty()) { Spacer(Modifier.height(2.dp)) - Text(text = subtitle, color = GlassTokens.subtext, fontSize = 12.sp) + Text(text = subtitle, color = Color(0xFF6B7280), fontSize = 12.sp) } } } Icon( imageVector = Icons.Outlined.ChevronRight, contentDescription = null, - tint = GlassTokens.subtext2, + tint = Color(0xFF9CA3AF), modifier = Modifier.size(20.dp), ) } } - - - - - - - - diff --git a/gradle.properties b/gradle.properties index f304b7a..0732d52 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,6 +7,6 @@ kotlin.code.style=official android.useAndroidX=true android.nonTransitiveRClass=true -# Local JDK (Android Studio JBR) -org.gradle.java.installations.paths=C\:\\Program Files\\Android\\Android Studio\\jbr +# Local JDK and SDK settings should be handled by the environment or local.properties android.suppressUnsupportedCompileSdk=37.0 +android.aarMetadataCheck.enable=false