1、首页性能优化

This commit is contained in:
beyond committed 2026-08-29 21:14:46 +08:00
1 parent eaf5e432ff
commit bbbebe38eb
11 files changed
+778 -258

No files matched your search

@@ -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,
@@ -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<EmbyItem> = 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<EmbyItem> = withContext(Dispatchers.IO) {
val term = query.trim()
@@ -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<EmbyMediaFolder>,
val resumeItems: List<EmbyItem>,
val latestItems: List<EmbyItem>,
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<EmbyItem>,
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<EmbyMediaFolder>, 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<EmbyMediaFolder>, 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<String>, values: List<Int>) {
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<List<EmbyMediaFolder>>(emptyList()) }
var resumeItems by remember { mutableStateOf<List<EmbyItem>>(emptyList()) }
var latestItems by remember { mutableStateOf<List<EmbyItem>>(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<List<EmbyMediaFolder>>(emptyList()) }
var resumeItems by remember(homeKey) { mutableStateOf<List<EmbyItem>>(emptyList()) }
var latestItems by remember(homeKey) { mutableStateOf<List<EmbyItem>>(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<Rect?>(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),
)
}
}
}
@@ -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),
)
}
}
}
@@ -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),
) {
@@ -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(),
@@ -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 }
@@ -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)
+7
View File
@@ -58,6 +58,13 @@
<string name="action_all">All</string>
<string name="latest_additions">Latest</string>
<string name="continue_watching">Continue Watching</string>
<string name="resource_movies">Movies</string>
<string name="resource_shows">Shows</string>
<string name="resource_episodes">Episodes</string>
<string name="resource_stats_title">Resource count</string>
<string name="resource_with_versions">With versions</string>
<string name="resource_without_versions">Without versions</string>
<string name="resource_close">Close</string>
<string name="icon_default_badge">Default</string>
<string name="set_default_library">Set as default icon library</string>
<string name="set_default_library_sub">Used first when changing library icons</string>
+7
View File
@@ -59,6 +59,13 @@
<string name="action_all">全部</string>
<string name="continue_watching">继续观看</string>
<string name="latest_additions">最新添加</string>
<string name="resource_movies">电影</string>
<string name="resource_shows">节目</string>
<string name="resource_episodes">集数</string>
<string name="resource_stats_title">资源数量</string>
<string name="resource_with_versions">包含多版本</string>
<string name="resource_without_versions">不含多版本</string>
<string name="resource_close">关闭</string>
<string name="icon_default_badge">默认</string>
<string name="set_default_library">设为默认图标库</string>
<string name="set_default_library_sub">资源库更换图标时优先使用该库</string>
@@ -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))
}
}