重构页面导航并修复底栏误跳转

This commit is contained in:
BoatingCloud committed 2026-08-27 17:39:37 +08:00
1 parent 5d41a0b95f
commit fa645b0a67
23 files changed
+1451 -443

No files matched your search

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