From bbbebe38ebee0c7ec814fc300e905261d5f3a54e Mon Sep 17 00:00:00 2001 From: beyond Date: Sat, 29 Aug 2026 21:14:46 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E9=A6=96=E9=A1=B5=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../myapplication/data/EmbyApiModels.kt | 11 + .../myapplication/data/EmbyRepository.kt | 73 +- .../myapplication/screens/HomeScreen.kt | 708 +++++++++++++----- .../example/myapplication/ui/AppBottomBar.kt | 107 ++- .../example/myapplication/ui/LiquidGlass.kt | 19 +- .../ui/design/LumaDesignSystem.kt | 3 +- .../ui/liquidglass/DragGestureInspector.kt | 8 +- .../ui/liquidglass/LiquidBottomTabs.kt | 59 +- app/src/main/res/values-en/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + .../screens/HomeScreenDataTest.kt | 34 + 11 files changed, 778 insertions(+), 258 deletions(-) create mode 100644 app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt index 5bec69c..bdf0ebe 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt @@ -154,6 +154,17 @@ data class EmbyMediaStream( @SerializedName("IsForced") val isForced: Boolean = false, ) +data class EmbyItemCounts( + @SerializedName("MovieCount") val movieCount: Int = 0, + @SerializedName("SeriesCount") val seriesCount: Int = 0, + @SerializedName("EpisodeCount") val episodeCount: Int = 0, +) + +data class EmbyResourceStats( + val withVersions: EmbyItemCounts = EmbyItemCounts(), + val withoutVersions: EmbyItemCounts = EmbyItemCounts(), +) + data class EmbyChapter( @SerializedName("StartPositionTicks") val startPositionTicks: Long = 0L, @SerializedName("Name") val name: String? = null, diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt index 4271b2b..67ad18e 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt @@ -8,6 +8,7 @@ import com.google.gson.reflect.TypeToken import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -278,9 +279,15 @@ object EmbyRepository { */ suspend fun getLatestItems(server: EmbyServer, limit: Int = 16): List = withContext(Dispatchers.IO) { try { - val url = "${server.fullUrl}/emby/Users/${server.userId}/Items/Latest?Limit=$limit&Fields=ProductionYear,Genres" + val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items/Latest").buildUpon() + .appendQueryParameter("Limit", limit.toString()) + .appendQueryParameter("IncludeItemTypes", "Movie,Series") + .appendQueryParameter("Fields", "ProductionYear,Genres,Overview,ImageTags,BackdropImageTags") + .appendQueryParameter("EnableImages", "true") + .appendQueryParameter("EnableImageTypes", "Primary,Backdrop,Logo") + .build() val request = Request.Builder() - .url(url) + .url(url.toString()) .header("X-Emby-Token", server.apiKey) .get() .build() @@ -293,6 +300,68 @@ object EmbyRepository { } catch (_: Exception) { emptyList() } } + /** + * Counts the server's video resources in both raw-file and grouped-title form. + * Emby's Items/Counts includes separate media versions, while the user-items + * queries return the grouped counts shown by Emby clients. + */ + suspend fun getResourceStats(server: EmbyServer): EmbyResourceStats = withContext(Dispatchers.IO) { + fun groupedCount(itemType: String): Int? = try { + val url = Uri.parse("${server.fullUrl}/emby/Users/${server.userId}/Items").buildUpon() + .appendQueryParameter("Recursive", "true") + .appendQueryParameter("IncludeItemTypes", itemType) + .appendQueryParameter("Limit", "1") + .appendQueryParameter("EnableImages", "false") + .appendQueryParameter("EnableTotalRecordCount", "true") + .build() + val request = Request.Builder() + .url(url.toString()) + .header("X-Emby-Token", server.apiKey) + .get() + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) null + else gson.fromJson(response.body?.string().orEmpty(), EmbyItemsResponse::class.java)?.totalRecordCount + } + } catch (_: Exception) { + null + } + + val movieCount = async { groupedCount("Movie") } + val seriesCount = async { groupedCount("Series") } + val episodeCount = async { groupedCount("Episode") } + val rawCounts = async { + try { + val url = Uri.parse("${server.fullUrl}/emby/Items/Counts").buildUpon() + .appendQueryParameter("UserId", server.userId) + .build() + val request = Request.Builder() + .url(url.toString()) + .header("X-Emby-Token", server.apiKey) + .get() + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) null + else gson.fromJson(response.body?.string().orEmpty(), EmbyItemCounts::class.java) + } + } catch (_: Exception) { + null + } + } + + val grouped = EmbyItemCounts( + movieCount = movieCount.await() ?: 0, + seriesCount = seriesCount.await() ?: 0, + episodeCount = episodeCount.await() ?: 0, + ) + val raw = rawCounts.await() + + EmbyResourceStats( + withVersions = raw ?: grouped, + withoutVersions = grouped, + ) + } + /** Search movies and series in the authenticated Emby library. */ suspend fun searchItems(server: EmbyServer, query: String, limit: Int = 40): List = withContext(Dispatchers.IO) { val term = query.trim() diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index d9e74c6..31dbc2a 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -17,15 +17,16 @@ 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.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset 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.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -43,29 +44,42 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale +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.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.TextUnit import coil.request.ImageRequest +import coil.imageLoader import com.example.myapplication.R as Res import com.example.myapplication.data.EmbyItem import com.example.myapplication.data.EmbyLibraryPage import com.example.myapplication.data.EmbyMediaFolder import com.example.myapplication.data.EmbyRepository +import com.example.myapplication.data.EmbyResourceStats import com.example.myapplication.data.EmbyServer +import com.example.myapplication.ui.GlassDialog import com.example.myapplication.ui.GlassTokens +import com.example.myapplication.ui.LiquidGlassPopupOverlay import com.example.myapplication.ui.PosterImmersivePalette import com.example.myapplication.ui.rememberPosterImmersiveBackground -import com.example.myapplication.ui.LiquidGlassTopBar import com.example.myapplication.ui.bottomBarContentPadding import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaIconView @@ -73,30 +87,23 @@ import com.example.myapplication.ui.design.LumaAsyncImage import com.example.myapplication.ui.design.LumaMediaCard import com.example.myapplication.ui.design.LumaEmptyState import com.example.myapplication.ui.design.LumaErrorState -import com.example.myapplication.ui.design.LumaSkeleton +import com.example.myapplication.ui.design.LumaLoading import com.example.myapplication.ui.design.LumaMotion import com.example.myapplication.ui.design.LumaShapes import com.example.myapplication.ui.design.LumaSpacing +import com.example.myapplication.ui.design.LumaGlassLevel +import com.example.myapplication.ui.design.lumaGlass +import com.example.myapplication.ui.design.rememberLumaLayerBackdrop import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import kotlinx.coroutines.async import kotlinx.coroutines.delay import top.yukonga.miuix.kmp.basic.Text -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.text.TextStyle -import com.example.myapplication.ui.AppTheme import androidx.compose.runtime.mutableIntStateOf +import coil.compose.AsyncImage +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop /* ---- Adaptive content colors for the immersive home background ---- */ val LocalHomeContentColor = staticCompositionLocalOf { Color.Unspecified } @@ -107,6 +114,7 @@ private data class HomeSnapshot( val libraries: List, val resumeItems: List, val latestItems: List, + val resourceStats: EmbyResourceStats, ) private object HomeContentCache { @@ -139,6 +147,19 @@ private object HomeContentCache { private fun homeCacheKey(server: EmbyServer): String = listOf(server.id, server.fullUrl, server.userId).joinToString("|") +internal fun heroDisplayTitle(item: EmbyItem): String = + item.seriesName?.takeIf { item.type == "Episode" && it.isNotBlank() } ?: item.name + +internal fun formatPlaybackElapsed(positionMs: Long): String? { + if (positionMs <= 0L) return null + val totalSeconds = positionMs / 1_000L + val hours = totalSeconds / 3600L + val minutes = (totalSeconds % 3600L) / 60L + val seconds = totalSeconds % 60L + return if (hours > 0L) "%d:%02d:%02d".format(hours, minutes, seconds) + else "%02d:%02d".format(minutes, seconds) +} + @Composable private fun homeContentColor(): Color { val value = LocalHomeContentColor.current @@ -163,9 +184,11 @@ private fun HeroCarousel( items: List, serverUrl: String, pagerState: PagerState, + heroHeight: androidx.compose.ui.unit.Dp, immersiveBackground: PosterImmersivePalette, onItemClick: (EmbyItem) -> Unit = {}, ) { + val context = LocalContext.current LaunchedEffect(pagerState, items.size) { while (true) { delay(5000) @@ -175,87 +198,31 @@ private fun HeroCarousel( } } - Column( - Modifier - .background( - Brush.verticalGradient( - 0f to Color.Transparent, - 0.72f to immersiveBackground.hero.copy(alpha = 0.16f), - 1f to immersiveBackground.pageTop.copy(alpha = 0.68f), - ) - ) - .padding(bottom = LumaSpacing.sm) - ) { + val nextItem = items.getOrNull((pagerState.currentPage + 1) % items.size.coerceAtLeast(1)) + LaunchedEffect(nextItem?.id, serverUrl) { + nextItem ?: return@LaunchedEffect + heroImageUrl(nextItem, serverUrl)?.let { url -> + context.imageLoader.enqueue(ImageRequest.Builder(context).data(url).size(1200).build()) + } + nextItem.getLogoUrl(serverUrl)?.let { url -> + context.imageLoader.enqueue(ImageRequest.Builder(context).data(url).size(600).build()) + } + } + + Column(Modifier.padding(bottom = LumaSpacing.sm)) { HorizontalPager( state = pagerState, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(heroHeight), ) { page -> FeaturedItemBanner( item = items[page], serverUrl = serverUrl, + heroHeight = heroHeight, immersive = immersiveBackground, onClick = { onItemClick(items[page]) }, ) } - AnimatedContent( - targetState = items.getOrNull(pagerState.currentPage), - transitionSpec = { fadeIn(tween(260)) togetherWith fadeOut(tween(180)) }, - label = "heroMetadataTransition", - ) { item -> - item?.let { current -> - Column( - Modifier - .fillMaxWidth() - .padding(horizontal = 18.dp) - .offset(y = (-18).dp) - .padding(top = 22.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - current.communityRating?.takeIf { it > 0.0 }?.let { - Text( - text = "★ ${String.format("%.1f", it)}", - color = Color(0xFFFFC107), - fontSize = 13.sp, - fontWeight = FontWeight.Bold, - ) - Spacer(Modifier.width(10.dp)) - } - current.productionYear?.let { - Text("$it", color = homeContentColor().copy(alpha = 0.84f), fontSize = 12.sp) - Spacer(Modifier.width(10.dp)) - } - Text( - text = listOfNotNull( - current.type?.let { - when (it) { - "Series" -> stringResource(Res.string.detail_type_series) - "Movie" -> stringResource(Res.string.detail_type_movie) - else -> it - } - }, - current.genres.take(2).joinToString(" / ").takeIf { it.isNotBlank() }, - ).joinToString(" · "), - color = homeSecondaryColor(), - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - current.overview?.takeIf { it.isNotBlank() }?.let { overview -> - Text( - text = overview, - color = homeSecondaryColor(), - fontSize = 13.sp, - lineHeight = 20.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } - } - } - Spacer(Modifier.height(LumaSpacing.sm)) + Spacer(Modifier.height(10.dp)) Row( Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, @@ -287,24 +254,31 @@ private fun FeaturedItemBanner( item: EmbyItem, serverUrl: String, modifier: Modifier = Modifier, + heroHeight: androidx.compose.ui.unit.Dp, immersive: PosterImmersivePalette, onClick: () -> Unit = {}, ) { val imageUrl = heroImageUrl(item, serverUrl) + val interactionSource = remember { MutableInteractionSource() } Box( modifier .fillMaxWidth() - .aspectRatio(1.2f) - .clip(RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)) - .clickable(onClick = onClick) - .background(Color.Black), + .height(heroHeight) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onClick, + ) + .background(immersive.pageTop), ) { if (imageUrl != null) { - LumaAsyncImage( + AsyncImage( model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(), contentDescription = item.name, contentScale = ContentScale.Crop, + placeholder = ColorPainter(immersive.pageTop), + error = ColorPainter(immersive.pageTop), modifier = Modifier.fillMaxSize(), ) } @@ -318,35 +292,97 @@ private fun FeaturedItemBanner( Box(Modifier.fillMaxSize().background( Brush.verticalGradient( 0f to Color.Transparent, - 0.54f to Color.Transparent, - 0.78f to immersive.hero.copy(alpha = 0.52f), - 1f to immersive.hero.copy(alpha = 0.96f), + 0.38f to Color.Transparent, + 0.56f to immersive.hero.copy(alpha = 0.10f), + 0.72f to immersive.hero.copy(alpha = 0.34f), + 0.88f to immersive.pageTop.copy(alpha = 0.76f), + 1f to immersive.pageTop, ) )) - Column(Modifier.align(Alignment.BottomStart).padding(start = 18.dp, end = 18.dp, bottom = 24.dp)) { + Column( + Modifier.align(Alignment.BottomCenter).padding(start = 24.dp, end = 24.dp, bottom = 22.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { val logoUrl = item.getLogoUrl(serverUrl) - if (logoUrl != null) { - LumaAsyncImage( - model = ImageRequest.Builder(LocalContext.current).data(logoUrl).crossfade(true).build(), - contentDescription = null, - contentScale = ContentScale.Fit, - modifier = Modifier.height(60.dp).widthIn(max = 200.dp) - ) - } else { + var logoLoaded by remember(logoUrl) { mutableStateOf(false) } + Box(Modifier.fillMaxWidth().height(72.dp), contentAlignment = Alignment.Center) { + if (logoUrl != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current).data(logoUrl).crossfade(true).build(), + contentDescription = null, + contentScale = ContentScale.Fit, + placeholder = ColorPainter(Color.Transparent), + error = ColorPainter(Color.Transparent), + onLoading = { logoLoaded = false }, + onSuccess = { logoLoaded = true }, + onError = { logoLoaded = false }, + modifier = Modifier + .height(72.dp) + .widthIn(max = 280.dp) + .alpha(if (logoLoaded) 1f else 0f), + ) + } + if (!logoLoaded) { + Text( + text = heroDisplayTitle(item), + color = Color.White, + fontSize = 30.sp, + fontWeight = FontWeight.Black, + letterSpacing = 1.6.sp, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = LocalTextStyle.current.copy( + shadow = Shadow(color = Color.Black.copy(alpha = 0.78f), offset = Offset(0f, 3f), blurRadius = 14f), + ), + ) + } + } + Row( + Modifier.fillMaxWidth().height(20.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + item.communityRating?.takeIf { it > 0.0 }?.let { + Text("★ ${String.format("%.1f", it)}", color = Color(0xFFFFC107), fontSize = 12.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.width(8.dp)) + } + item.productionYear?.let { + Text("$it", color = Color.White.copy(alpha = 0.88f), fontSize = 12.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.width(8.dp)) + } Text( - text = item.name, - color = Color.White, - fontSize = 28.sp, - fontWeight = FontWeight.Black, + text = listOfNotNull( + item.type?.let { + when (it) { + "Series" -> stringResource(Res.string.detail_type_series) + "Movie" -> stringResource(Res.string.detail_type_movie) + else -> it + } + }, + item.genres.take(2).joinToString(" / ").takeIf { it.isNotBlank() }, + ).joinToString(" · "), + color = Color.White.copy(alpha = 0.82f), + fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, - style = LocalTextStyle.current.copy( - shadow = Shadow(color = Color.Black.copy(alpha = 0.60f), offset = Offset(0f, 2f), blurRadius = 10f), - ), ) } - + Box(Modifier.fillMaxWidth().height(42.dp), contentAlignment = Alignment.TopCenter) { + item.overview?.takeIf { it.isNotBlank() }?.let { overview -> + Text( + text = overview, + color = Color.White.copy(alpha = 0.82f), + fontSize = 12.sp, + lineHeight = 18.sp, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } } } } @@ -384,6 +420,7 @@ private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClic .toFloat() .coerceIn(0f, 1f) } + val elapsed = formatPlaybackElapsed(item.playbackPositionMs) LumaMediaCard( model = imageUrl?.let { ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build() @@ -398,16 +435,25 @@ private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClic progress = progress, onClick = { onItemClick(item) }, ) { - Box( - Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.20f)), - contentAlignment = Alignment.Center, + Row( + Modifier + .align(Alignment.BottomEnd) + .padding(end = 8.dp, bottom = 12.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color.Black.copy(alpha = 0.64f)) + .padding(horizontal = 9.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), ) { LumaIconView( icon = LumaIcon.Play, contentDescription = null, tint = Color.White, - modifier = Modifier.size(36.dp), + modifier = Modifier.size(14.dp), ) + elapsed?.let { + Text(it, color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.SemiBold) + } } } } @@ -485,13 +531,13 @@ private fun MyMediaGrid(libraries: List, serverUrl: String, onS LazyRow(horizontalArrangement = Arrangement.spacedBy(12.dp)) { items(libraries) { folder -> Column( - Modifier.width(120.dp), + Modifier.width(176.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), ) { val folderImageUrl = "$serverUrl/emby/Items/${folder.id}/Images/Primary?maxWidth=300&quality=80" Box( - Modifier.size(120.dp) + Modifier.width(176.dp).height(100.dp) .clip(RoundedCornerShape(LumaShapes.medium)) .background(homeSecondaryColor().copy(alpha = 0.1f)) .clickable { onSelect(folder) }, @@ -516,6 +562,178 @@ private fun MyMediaGrid(libraries: List, serverUrl: String, onS } } +@Composable +private fun ResourceStatsCard( + stats: EmbyResourceStats, + onClick: () -> Unit, +) { + val labels = listOf( + stringResource(Res.string.resource_movies), + stringResource(Res.string.resource_shows), + stringResource(Res.string.resource_episodes), + ) + val values = stats.withVersions.run { listOf(movieCount, seriesCount, episodeCount) } + Row( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + labels.zip(values).forEach { (label, value) -> + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = label, + color = homeContentColor(), + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + RainbowNumber( + text = String.format("%,d", value), + fontSize = 18.sp, + ) + } + } + } +} + +@Composable +private fun ResourceStatsDialog( + stats: EmbyResourceStats, + backdrop: Backdrop?, + onDismiss: () -> Unit, +) { + val labels = listOf( + stringResource(Res.string.resource_movies), + stringResource(Res.string.resource_shows), + stringResource(Res.string.resource_episodes), + ) + val dialogTint = if (GlassTokens.isDark) Color(0xFF111821).copy(alpha = 0.90f) + else Color(0xFFF4F7FA).copy(alpha = 0.90f) + GlassDialog( + backdrop = backdrop, + onDismissRequest = onDismiss, + modifier = Modifier.width(330.dp), + surfaceTint = dialogTint, + blurRadius = 22.dp, + ) { + Box(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(Res.string.resource_stats_title), + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + color = GlassTokens.text, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + ResourceStatsGroup( + title = stringResource(Res.string.resource_with_versions), + labels = labels, + values = stats.withVersions.run { listOf(movieCount, seriesCount, episodeCount) }, + ) + ResourceStatsGroup( + title = stringResource(Res.string.resource_without_versions), + labels = labels, + values = stats.withoutVersions.run { listOf(movieCount, seriesCount, episodeCount) }, + ) + } + + val closeInteraction = remember { MutableInteractionSource() } + Box( + Modifier + .align(Alignment.TopEnd) + .offset(x = 10.dp, y = (-10).dp) + .size(40.dp) + .lumaGlass( + backdrop = backdrop, + radius = 20.dp, + level = LumaGlassLevel.Interactive, + tint = if (GlassTokens.isDark) { + Color(0xFF334252).copy(alpha = 0.48f) + } else { + Color.White.copy(alpha = 0.36f) + }, + shadowElevation = 4.dp, + ) + .clickable( + interactionSource = closeInteraction, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, + ) { + LumaIconView( + icon = LumaIcon.Close, + contentDescription = stringResource(Res.string.resource_close), + tint = GlassTokens.text, + modifier = Modifier.size(17.dp), + ) + } + } + } +} + +@Composable +private fun ResourceStatsGroup(title: String, labels: List, values: List) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(title, color = GlassTokens.subtext, fontSize = 12.sp, fontWeight = FontWeight.Bold) + Row(Modifier.fillMaxWidth()) { + labels.zip(values).forEach { (label, value) -> + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + Text( + label, + color = GlassTokens.text, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + RainbowNumber(String.format("%,d", value), fontSize = 16.sp) + } + } + } + } +} + +@Composable +private fun RainbowNumber( + text: String, + fontSize: TextUnit, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier, + style = TextStyle( + brush = Brush.linearGradient( + listOf( + Color(0xFFFF5F6D), + Color(0xFFFFB347), + Color(0xFF36D1DC), + Color(0xFF5B86E5), + Color(0xFFB06AB3), + ) + ), + fontSize = fontSize, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, + ), + maxLines = 1, + ) +} + @OptIn(ExperimentalAnimationApi::class) @Composable fun HomeScreen( @@ -527,12 +745,20 @@ fun HomeScreen( val activeServer = remember(servers, selected) { selected ?: servers.firstOrNull { it.apiKey.isNotBlank() } } val homeKey = activeServer?.let(::homeCacheKey) - var libraries by remember { mutableStateOf>(emptyList()) } - var resumeItems by remember { mutableStateOf>(emptyList()) } - var latestItems by remember { mutableStateOf>(emptyList()) } - var loading by remember { mutableStateOf(false) } - var loadError by remember { mutableStateOf(false) } - var reloadToken by remember { mutableIntStateOf(0) } + var libraries by remember(homeKey) { mutableStateOf>(emptyList()) } + var resumeItems by remember(homeKey) { mutableStateOf>(emptyList()) } + var latestItems by remember(homeKey) { mutableStateOf>(emptyList()) } + var resourceStats by remember(homeKey) { mutableStateOf(EmbyResourceStats()) } + var loading by remember(homeKey) { mutableStateOf(activeServer != null) } + var loadError by remember(homeKey) { mutableStateOf(false) } + var reloadToken by remember(homeKey) { mutableIntStateOf(0) } + var showResourceStats by remember(homeKey) { mutableStateOf(false) } + var serverMenuVisible by remember(homeKey) { mutableStateOf(false) } + var serverMenuMounted by remember(homeKey) { mutableStateOf(false) } + var serverAnchorBounds by remember { mutableStateOf(null) } + var homeRootOrigin by remember { mutableStateOf(Offset.Zero) } + val homeBackdrop = rememberLumaLayerBackdrop() + val heroHeight = (LocalConfiguration.current.screenHeightDp.dp * 0.5f) val heroItems = remember(latestItems) { latestItems.take(8) } val heroPagerState = rememberPagerState(pageCount = { heroItems.size }) @@ -550,6 +776,7 @@ fun HomeScreen( libraries = snapshot.libraries latestItems = snapshot.latestItems resumeItems = snapshot.resumeItems + resourceStats = snapshot.resourceStats loadError = false loading = false return@LaunchedEffect @@ -563,16 +790,20 @@ fun HomeScreen( val libsDeferred = async { EmbyRepository.getLibraries(server) } val latestDeferred = async { EmbyRepository.getLatestItems(server, limit = 10) } val resumeDeferred = async { EmbyRepository.getResumeItems(server, limit = 10) } + val statsDeferred = async { EmbyRepository.getResourceStats(server) } - libraries = libsDeferred.await() latestItems = latestDeferred.await() resumeItems = resumeDeferred.await() + loading = false + libraries = libsDeferred.await() + resourceStats = statsDeferred.await() HomeContentCache.putSnapshot( cacheKey, HomeSnapshot( libraries = libraries, resumeItems = resumeItems, latestItems = latestItems, + resourceStats = resourceStats, ), ) loadError = false @@ -592,14 +823,24 @@ fun HomeScreen( LocalHomeSecondaryColor provides adaptiveSecondary, LocalHomeAccentColor provides adaptiveAccent, ) { - Box(Modifier.fillMaxSize().background( - Brush.verticalGradient( - 0f to immersiveBackground.pageTop, - 0.48f to immersiveBackground.hero, - 1f to immersiveBackground.pageBottom, - ) - )) { - AnimatedContent( + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { homeRootOrigin = it.positionInRoot() }, + ) { + Box( + Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to immersiveBackground.pageTop, + 0.54f to immersiveBackground.pageTop, + 1f to immersiveBackground.pageBottom, + ) + ) + .then(if (homeBackdrop != null) Modifier.layerBackdrop(homeBackdrop) else Modifier), + ) { + AnimatedContent( targetState = loading, transitionSpec = { fadeIn(tween(LumaMotion.standard)) togetherWith fadeOut(tween(LumaMotion.standard)) @@ -611,25 +852,8 @@ fun HomeScreen( EmptyHomeState() } } else if (isLoading) { - Column( - Modifier - .fillMaxSize() - .padding(horizontal = LumaSpacing.md, vertical = LumaSpacing.xl), - verticalArrangement = Arrangement.spacedBy(LumaSpacing.md), - ) { - LumaSkeleton(Modifier.fillMaxWidth().height(290.dp), RoundedCornerShape(LumaShapes.large)) - LumaSkeleton(Modifier.fillMaxWidth(0.48f).height(22.dp), RoundedCornerShape(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - repeat(2) { - LumaSkeleton(Modifier.weight(1f).height(170.dp), RoundedCornerShape(LumaShapes.small)) - } - } - LumaSkeleton(Modifier.fillMaxWidth(0.34f).height(22.dp), RoundedCornerShape(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - repeat(3) { - LumaSkeleton(Modifier.weight(1f).height(150.dp), RoundedCornerShape(LumaShapes.small)) - } - } + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LumaLoading(label = stringResource(Res.string.loading_home)) } } else if (loadError) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -653,6 +877,7 @@ fun HomeScreen( items = heroItems, serverUrl = server.fullUrl, pagerState = heroPagerState, + heroHeight = heroHeight, immersiveBackground = immersiveBackground, onItemClick = { onOpenDetail(it.id) } ) @@ -663,12 +888,6 @@ fun HomeScreen( Column( Modifier .fillMaxWidth() - .background( - Brush.verticalGradient( - 0f to immersiveBackground.pageTop.copy(alpha = 0.52f), - 0.38f to Color.Transparent, - ) - ) .padding(start = LumaSpacing.md, end = LumaSpacing.md, top = LumaSpacing.md), verticalArrangement = Arrangement.spacedBy(LumaSpacing.xl), ) { @@ -704,17 +923,68 @@ fun HomeScreen( libraries.forEach { folder -> LazyLibrarySection(folder, server) { onOpenDetail(it.id) } } + + ResourceStatsCard( + stats = resourceStats, + onClick = { showResourceStats = true }, + ) + Spacer(Modifier.height(LumaSpacing.md)) } } } } + } } HomeOverlayTopBar( - name = activeServer?.name ?: stringResource(Res.string.home_not_connected), - iconUrl = activeServer?.iconUrl ?: "", - modifier = Modifier.align(Alignment.TopCenter), + server = activeServer, + backdrop = homeBackdrop, + onClick = { + if (servers.any { it.apiKey.isNotBlank() }) { + serverMenuMounted = true + serverMenuVisible = true + } + }, + onBoundsChanged = { serverAnchorBounds = it }, + modifier = Modifier.align(Alignment.TopStart), ) + + val anchor = serverAnchorBounds + val selectableServers = servers.filter { it.apiKey.isNotBlank() } + if (serverMenuMounted && anchor != null) { + LiquidGlassPopupOverlay( + anchorBounds = anchor, + rootOrigin = homeRootOrigin, + backdrop = homeBackdrop, + visible = serverMenuVisible, + onDismissRequest = { serverMenuVisible = false }, + onClosed = { serverMenuMounted = false }, + width = 190.dp, + height = minOf(300.dp, (selectableServers.size * 52 + 8).dp), + expandToRight = true, + placeBelowAnchor = true, + anchorGap = 7.dp, + ) { + selectableServers.forEach { server -> + HomeServerOption( + server = server, + selected = server.id == activeServer?.id, + onClick = { + serverMenuVisible = false + if (server.id != activeServer?.id) onSelectServer(server.id) + }, + ) + } + } + } + + if (showResourceStats) { + ResourceStatsDialog( + stats = resourceStats, + backdrop = homeBackdrop, + onDismiss = { showResourceStats = false }, + ) + } } } } @@ -739,24 +1009,118 @@ private fun EmptyHomeState() { } @Composable -private fun HomeOverlayTopBar(name: String, iconUrl: String, modifier: Modifier = Modifier) { - LiquidGlassTopBar(modifier) { +private fun HomeOverlayTopBar( + server: EmbyServer?, + backdrop: Backdrop?, + onClick: () -> Unit, + onBoundsChanged: (Rect) -> Unit, + modifier: Modifier = Modifier, +) { + Box(modifier.statusBarsPadding().padding(start = 12.dp, top = 8.dp)) { Row( - Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically + Modifier + .wrapContentWidth() + .height(48.dp) + .onGloballyPositioned { onBoundsChanged(it.boundsInRoot()) } + .lumaGlass(backdrop = backdrop, radius = 24.dp, level = LumaGlassLevel.Floating) + .clickable(onClick = onClick) + .padding(start = 5.dp, end = 14.dp), + verticalAlignment = Alignment.CenterVertically, ) { Box( - Modifier.size(40.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.16f)), - contentAlignment = Alignment.Center + Modifier.size(38.dp).clip(CircleShape).background(homeSecondaryColor().copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, ) { - if (iconUrl.isNotEmpty()) { - LumaAsyncImage(model = iconUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + if (!server?.iconUrl.isNullOrEmpty()) { + LumaAsyncImage(model = server.iconUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) } else { - LumaIconView(LumaIcon.Server, tint = Color.White, modifier = Modifier.size(22.dp)) + LumaIconView(LumaIcon.Server, tint = homeContentColor(), modifier = Modifier.size(20.dp)) } } - Spacer(Modifier.width(12.dp)) - Text(name, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold) + Spacer(Modifier.width(9.dp)) + Text( + server?.name ?: stringResource(Res.string.home_not_connected), + color = homeContentColor(), + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun HomeServerOption( + server: EmbyServer, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .height(52.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick, + ) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + Modifier.size(32.dp).clip(CircleShape).background(GlassTokens.glassFillSoft), + contentAlignment = Alignment.Center, + ) { + if (server.iconUrl.isNotBlank()) { + LumaAsyncImage( + model = server.iconUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + LumaIconView(LumaIcon.Server, contentDescription = null, tint = GlassTokens.text, modifier = Modifier.size(18.dp)) + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + server.name, + color = GlassTokens.text, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + Modifier + .size(6.dp) + .clip(CircleShape) + .background(if (server.isOnline) Color(0xFF34C759) else Color(0xFFFF453A)), + ) + Text( + if (server.isOnline) stringResource(Res.string.server_online) else stringResource(Res.string.server_offline), + color = GlassTokens.subtext, + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + ) + } + } + if (selected) { + LumaIconView( + LumaIcon.Check, + contentDescription = null, + tint = GlassTokens.accent, + modifier = Modifier.size(18.dp), + ) } } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt index 13caa9e..61cc23a 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt @@ -2,8 +2,9 @@ package com.example.myapplication.ui import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height @@ -13,19 +14,21 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.runtime.remember +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer 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 com.example.myapplication.ui.liquidglass.LiquidBottomTab import com.example.myapplication.ui.liquidglass.LiquidBottomTabs -import com.example.myapplication.ui.liquidglass.LocalLiquidBottomTabsGhostLayer import com.example.myapplication.ui.design.LumaColors import com.example.myapplication.ui.design.LumaGlassRuntime import com.example.myapplication.ui.design.LumaIcon @@ -38,6 +41,9 @@ import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.highlight.Highlight +import com.kyant.backdrop.shadow.InnerShadow +import com.kyant.backdrop.shadow.Shadow import com.kyant.shapes.Capsule import top.yukonga.miuix.kmp.basic.Text @@ -48,7 +54,8 @@ fun LiquidAppBottomBar( backdrop: Backdrop?, modifier: Modifier = Modifier, ) { - val inactive = LumaColors.text + val active = if (LumaColors.isDark) Color(0xFF0091FF) else Color(0xFF0088FF) + val inactive = LumaColors.textSecondary Row( modifier = modifier @@ -68,20 +75,19 @@ fun LiquidAppBottomBar( for (index in 0 until tabsCount) { val tab = mainTabs[index] LiquidBottomTab(onClick = { onSelect(index) }) { - val isGhostLayer = LocalLiquidBottomTabsGhostLayer.current LumaAnimatedIcon( icon = tab.icon, selected = selectedTab == index, contentDescription = stringResource(tab.labelRes), - tint = if (isGhostLayer) Color.White else if (selectedTab == index) LumaColors.accent else inactive, - modifier = Modifier.size(40.dp), + tint = if (selectedTab == index) active else inactive, + modifier = Modifier.size(26.dp), ) Spacer(Modifier.height(2.dp)) Text( text = stringResource(tab.labelRes), - color = if (isGhostLayer) Color.White else if (selectedTab == index) LumaColors.accent else inactive, - fontSize = 8.sp, - fontWeight = FontWeight.Medium, + color = if (selectedTab == index) active else inactive, + fontSize = 10.sp, + fontWeight = if (selectedTab == index) FontWeight.SemiBold else FontWeight.Medium, maxLines = 1, ) } @@ -93,9 +99,20 @@ fun LiquidAppBottomBar( val searchSelected = selectedTab == 4 val safeBackdrop = backdrop?.takeIf { LumaGlassRuntime.useNativeBackdrop } + val searchInteraction = remember { MutableInteractionSource() } + val searchPressed by searchInteraction.collectIsPressedAsState() + val searchScale by animateFloatAsState( + targetValue = if (searchPressed) 1.06f else 1f, + animationSpec = spring(dampingRatio = 0.72f, stiffness = 650f), + label = "bottomSearchPress", + ) Box( modifier = Modifier .size(width = 64.dp, height = 64.dp) + .graphicsLayer { + scaleX = searchScale + scaleY = searchScale + } .clip(CircleShape) .then( if (safeBackdrop != null) { @@ -104,20 +121,17 @@ fun LiquidAppBottomBar( shape = { Capsule() }, effects = { vibrancy() - blur(2f.dp.toPx()) - lens(12f.dp.toPx(), 16f.dp.toPx()) + blur(8f.dp.toPx()) + lens(24f.dp.toPx(), 24f.dp.toPx()) }, onDrawSurface = { drawRect( if (LumaColors.isDark) { - Color(0xFF1C2430).copy(alpha = 0.54f) + Color(0xFF121212).copy(alpha = 0.40f) } else { - Color(0xFFD4D9DF).copy(alpha = 0.78f) + Color(0xFFFAFAFA).copy(alpha = 0.40f) }, ) - if (searchSelected) { - drawRect(LumaColors.glassHighlight.copy(alpha = 0.40f)) - } }, ) } else { @@ -145,23 +159,56 @@ fun LiquidAppBottomBar( Modifier }, ) - .clickable(indication = null, interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }) { onSelect(4) }, + .clickable(indication = null, interactionSource = searchInteraction) { onSelect(4) }, contentAlignment = Alignment.Center, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - LumaIconView( - icon = LumaIcon.Search, - contentDescription = null, - tint = if (searchSelected) LumaColors.accent else inactive, - modifier = Modifier.size(44.dp), - ) - Text( - text = stringResource(Res.string.search), - color = if (searchSelected) LumaColors.accent else inactive, - fontSize = 8.sp, - fontWeight = FontWeight.ExtraBold, + if (searchSelected) { + Box( + Modifier + .size(56.dp) + .clip(CircleShape) + .then( + if (safeBackdrop != null) { + Modifier.drawBackdrop( + backdrop = safeBackdrop, + shape = { Capsule() }, + effects = { + lens( + 10f.dp.toPx(), + 14f.dp.toPx(), + chromaticAberration = true, + ) + }, + highlight = { Highlight.Default.copy(alpha = 0.72f) }, + shadow = { Shadow(alpha = 0.56f) }, + innerShadow = { InnerShadow(radius = 8.dp, alpha = 0.52f) }, + onDrawSurface = { + drawRect( + if (LumaColors.isDark) Color.White.copy(alpha = 0.10f) + else Color.Black.copy(alpha = 0.10f) + ) + }, + ) + } else { + Modifier.lumaFallbackGlass( + shape = CircleShape, + selected = true, + neutralVeil = if (LumaColors.isDark) { + Color(0xFF26313E).copy(alpha = 0.62f) + } else { + Color(0xFFC9D8E8).copy(alpha = 0.60f) + }, + ) + }, + ) ) } + LumaIconView( + icon = LumaIcon.Search, + contentDescription = stringResource(com.example.myapplication.R.string.search), + tint = if (searchSelected) active else inactive, + modifier = Modifier.size(27.dp), + ) } } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index 8fdefdf..27284d8 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -393,6 +393,9 @@ fun LiquidGlassPopupOverlay( modifier: Modifier = Modifier, width: Dp = 184.dp, height: Dp = 152.dp, + expandToRight: Boolean = false, + placeBelowAnchor: Boolean = false, + anchorGap: Dp = 6.dp, content: @Composable ColumnScope.() -> Unit, ) { val density = androidx.compose.ui.platform.LocalDensity.current @@ -428,8 +431,16 @@ fun LiquidGlassPopupOverlay( 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 menuLeft = if (expandToRight) { + anchorCenterX - initialSizePx / 2f + } else { + anchorCenterX + initialSizePx / 2f - menuWidthPx + } + val menuTop = if (placeBelowAnchor) { + anchorBounds.bottom - rootOrigin.y + with(density) { anchorGap.toPx() } + } else { + anchorCenterY - initialSizePx / 2f + } val menuRect = Rect( menuLeft, menuTop, @@ -599,6 +610,8 @@ fun GlassDialog( onDismissRequest: () -> Unit, backdrop: Backdrop? = null, modifier: Modifier = Modifier, + surfaceTint: Color? = null, + blurRadius: Dp? = null, content: @Composable () -> Unit, ) { Dialog(onDismissRequest = onDismissRequest) { @@ -616,8 +629,10 @@ fun GlassDialog( backdrop = glassBackdrop, radius = 24.dp, level = LumaGlassLevel.Strong, + tint = surfaceTint, borderColor = GlassTokens.glassBorder, shadowElevation = 10.dp, + blurOverride = blurRadius, ) .padding(20.dp), ) { diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt index 650846b..27e2341 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt @@ -300,6 +300,7 @@ fun Modifier.lumaGlass( borderColor: Color? = null, shadowElevation: Dp = 8.dp, highlight: Boolean = true, + blurOverride: Dp? = null, ): Modifier { val spec = remember(AppTheme.isDark, AppTheme.glassMode, level) { GlassSpec( @@ -328,7 +329,7 @@ fun Modifier.lumaGlass( saturation = 1.08f, ) vibrancy() - blur(spec.blur.toPx()) + blur((blurOverride ?: spec.blur).toPx()) if (level == LumaGlassLevel.Floating || level == LumaGlassLevel.Interactive) { lens( refractionHeight = if (level == LumaGlassLevel.Interactive) 8.dp.toPx() else 6.dp.toPx(), diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/DragGestureInspector.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/DragGestureInspector.kt index 678deba..36f5592 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/DragGestureInspector.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/DragGestureInspector.kt @@ -19,10 +19,8 @@ suspend fun PointerInputScope.inspectDragGestures( onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit ) { awaitEachGesture { - val initialDown = awaitFirstDown(false, PointerEventPass.Initial) - - val down = awaitFirstDown(false) - val drag = initialDown + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + val drag = down onDragStart(down) onDrag(drag, Offset.Zero) @@ -66,7 +64,7 @@ private suspend inline fun AwaitPointerEventScope.awaitDragOrUp( ): PointerInputChange? { var pointer = pointerId while (true) { - val event = awaitPointerEvent() + val event = awaitPointerEvent(PointerEventPass.Initial) val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null if (dragEvent.changedToUpIgnoreConsumed()) { val otherDown = event.changes.fastFirstOrNull { it.pressed } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt index 3d088ec..9570395 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity @@ -36,7 +35,6 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastRoundToInt -import androidx.compose.ui.util.lerp import com.kyant.backdrop.Backdrop import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.effects.blur @@ -71,16 +69,16 @@ fun LiquidBottomTabs( content: @Composable RowScope.() -> Unit ) { val isLightTheme = !LumaColors.isDark - val containerColor = LumaColors.clearTint + val containerColor = Color.Transparent val neutralTint = if (isLightTheme) { - Color(0xFFD4D9DF).copy(alpha = 0.78f) + Color(0xFFFAFAFA).copy(alpha = 0.40f) } else { - Color(0xFF1C2430).copy(alpha = 0.54f) + Color(0xFF121212).copy(alpha = 0.40f) } val selectedNeutralTint = if (isLightTheme) { - Color(0xFFC1C8D0).copy(alpha = 0.82f) + Color.Black.copy(alpha = 0.10f) } else { - Color(0xFF26313E).copy(alpha = 0.44f) + Color.White.copy(alpha = 0.10f) } val liquidFallback = com.example.myapplication.ui.AppTheme.glassMode == com.example.myapplication.ui.GlassMode.Liquid val fallbackContainerVeil = if (isLightTheme) { @@ -128,7 +126,7 @@ fun LiquidBottomTabs( valueRange = 0f..(tabsCount - 1).toFloat(), visibilityThreshold = 0.001f, initialScale = 1f, - pressedScale = 78f / 56f, + pressedScale = 1.06f, onDragStarted = {}, onDragStopped = { val targetIndex = targetValue.fastRoundToInt().fastCoerceIn(0, tabsCount - 1) @@ -168,19 +166,6 @@ fun LiquidBottomTabs( } } - val interactiveHighlight = remember(animationScope) { - InteractiveHighlight( - animationScope = animationScope, - position = { size, offset -> - Offset( - if (isLtr) (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset - else size.width - (dampedDragAnimation.value + 0.5f) * tabWidth + panelOffset, - size.height / 2f - ) - } - ) - } - if (!useBackdrop) { Box( Modifier @@ -201,8 +186,6 @@ fun LiquidBottomTabs( if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset } - .then(interactiveHighlight.gestureModifier) - .then(dampedDragAnimation.modifier) .height(56.dp) .width(with(density) { tabWidth.toDp() }) .lumaFallbackGlass( @@ -227,12 +210,6 @@ fun LiquidBottomTabs( blur(8f.dp.toPx()) lens(24f.dp.toPx(), 24f.dp.toPx()) }, - layerBlock = { - val progress = dampedDragAnimation.pressProgress - val scale = lerp(1f, 1f + 16f.dp.toPx() / size.width, progress) - scaleX = scale - scaleY = scale - }, onDrawSurface = { drawRect(containerColor) drawRect(neutralTint) @@ -250,14 +227,12 @@ fun LiquidBottomTabs( Box( Modifier .padding(horizontal = 4f.dp) - .clip(Capsule()) .graphicsLayer { translationX = if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset } - .then(interactiveHighlight.gestureModifier) - .then(dampedDragAnimation.modifier) + .clip(Capsule()) .drawBackdrop( backdrop = backdrop, shape = { Capsule() }, @@ -270,34 +245,26 @@ fun LiquidBottomTabs( }, highlight = { val progress = dampedDragAnimation.pressProgress - Highlight.Plain.copy(alpha = progress) + Highlight.Default.copy(alpha = 0.56f + progress * 0.44f) }, shadow = { val progress = dampedDragAnimation.pressProgress - Shadow(alpha = progress) + Shadow(alpha = 0.36f + progress * 0.64f) }, innerShadow = { val progress = dampedDragAnimation.pressProgress InnerShadow( - radius = 8f.dp * progress, - alpha = progress, + radius = 8f.dp, + alpha = 0.36f + progress * 0.64f, ) }, layerBlock = { scaleX = dampedDragAnimation.scaleX scaleY = dampedDragAnimation.scaleY - val velocity = dampedDragAnimation.velocity / 10f - scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.2f, 0.2f) - scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.2f, 0.2f) }, onDrawSurface = { val progress = dampedDragAnimation.pressProgress - drawRect(selectedNeutralTint) - drawRect( - if (isLightTheme) Color.Black.copy(0.1f) - else Color.White.copy(0.1f), - alpha = 1f - progress, - ) + drawRect(selectedNeutralTint, alpha = 1f - progress) drawRect(Color.Black.copy(alpha = 0.03f * progress)) }, ) @@ -312,8 +279,8 @@ fun LiquidBottomTabs( .graphicsLayer { translationX = panelOffset } + .then(dampedDragAnimation.modifier) .clip(Capsule()) - .then(interactiveHighlight.modifier) .height(64f.dp) .fillMaxWidth() .padding(LumaSpacing.xxs) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 94c89d9..69a2b2c 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -58,6 +58,13 @@ All Latest Continue Watching + Movies + Shows + Episodes + Resource count + With versions + Without versions + Close Default Set as default icon library Used first when changing library icons diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6ab011..1762382 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,6 +59,13 @@ 全部 继续观看 最新添加 + 电影 + 节目 + 集数 + 资源数量 + 包含多版本 + 不含多版本 + 关闭 默认 设为默认图标库 资源库更换图标时优先使用该库 diff --git a/app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt b/app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt new file mode 100644 index 0000000..a56b393 --- /dev/null +++ b/app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt @@ -0,0 +1,34 @@ +package com.example.myapplication.screens + +import com.example.myapplication.data.EmbyItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HomeScreenDataTest { + @Test + fun heroTitleUsesSeriesNameForEpisodeFallback() { + val episode = EmbyItem( + id = "episode-1", + name = "Episode 7", + type = "Episode", + seriesName = "The Show", + ) + + assertEquals("The Show", heroDisplayTitle(episode)) + } + + @Test + fun heroTitleKeepsMovieName() { + val movie = EmbyItem(id = "movie-1", name = "The Movie", type = "Movie") + + assertEquals("The Movie", heroDisplayTitle(movie)) + } + + @Test + fun elapsedPlaybackFormatsMilliseconds() { + assertEquals("12:34", formatPlaybackElapsed(754_000L)) + assertEquals("1:02:03", formatPlaybackElapsed(3_723_000L)) + assertNull(formatPlaybackElapsed(0L)) + } +}