Local snapshot before synchronization

This commit is contained in:
BoatingCloud committed 2026-08-26 22:09:52 +08:00
1 parent 668a4f8274
commit 08522d6de4
13 files changed
+1016 -3433

No files matched your search

+9 -4
View File
@@ -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
}
}
@@ -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<IconItem>, 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),
)
}
}
}
}
}
}
@@ -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(
@@ -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<EmbyItem> = withContext(Dispatchers.IO) {
suspend fun getFavoriteItems(server: EmbyServer, limit: Int = 50): List<EmbyItem> = withContext(Dispatchers.IO) {
try {
val params = mutableListOf<String>()
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<EmbyItem> = 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<EmbyItem> = 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<EmbyItem> = 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"
}
}
@@ -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<EmbyItem>)
/* ---- Shared home UI state (top bar shows selected library) ---- */
/* ---- Shared home UI state ---- */
object HomeUiState {
var selectedLibrary by mutableStateOf<EmbyMediaFolder?>(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<EmbyItem>,
@@ -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<EmbyLibraryPage?>(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<EmbyMediaFolder>, 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<EmbyMediaFolder>, 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<EmbyServer> = emptyList(),
@@ -605,6 +470,7 @@ fun HomeScreen(
var resumeItems by remember { mutableStateOf<List<EmbyItem>>(emptyList()) }
var latestItems by remember { mutableStateOf<List<EmbyItem>>(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)
}
}
}
File diff suppressed because it is too large. Load diff
@@ -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}分"}"
}
@@ -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<List<EmbyItem>>(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
)
}
}
@@ -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<SettingItem>, 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<SettingItem>, 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())
}
}
}
}
@@ -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<Backdrop?> { 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<SubtitleTrackInfo>()
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)
}
}
@@ -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),
)
}
@@ -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<Color>,
@@ -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<Color>(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<String>,
@@ -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),
)
}
}
+2 -2
View File
@@ -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