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))
+ }
+}