重构页面导航并修复底栏误跳转
This commit is contained in:
1 parent
5d41a0b95f
commit
fa645b0a67
23 files changed
+1451
-443
No files matched your search
@@ -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
|
||||
) {
|
||||
|
||||
+27
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)。
|
||||
@@ -7,3 +7,5 @@
|
||||
- 中:远端提交与本地大范围未提交 UI 改动并行时,先暂存本地改动、合并远端非冲突文件,再恢复本地改动并手动吸收远端业务行为,避免整文件覆盖。
|
||||
- 高:Backdrop 的来源层与消费玻璃必须是同级兄弟层;菜单外部手势层也必须位于菜单内容下方,否则打开手势会误触发关闭。
|
||||
- 中:Backdrop 的玻璃效果是否明显取决于来源内容;纯白卡片上无法显示明显折射,需依靠仓库的高光、阴影和 lens 边缘表现,不能用普通白色背景冒充。
|
||||
- 高:Root Overlay 若直接消费外层页面 `layerBackdrop`,外层 source 会把 Overlay 再捕获形成 RenderNode 循环;必须为局部页面建立只包裹底层内容的 source,并让 Overlay 作为兄弟 consumer。
|
||||
- 高:Room 实体由 Java 可空字段映射时,迁移列定义必须与 Room 的 `notNull` 预期一致;新增 `protocol` 使用可空列并在模型读取时回退 HTTPS,否则旧数据库启动会因迁移校验失败崩溃。
|
||||
@@ -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` 成功。
|
||||
Reference in new issue
Block a user