Local snapshot before synchronization
This commit is contained in:
1 parent
668a4f8274
commit
08522d6de4
13 files changed
+1016
-3433
No files matched your search
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user