1、首页性能优化
This commit is contained in:
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(),
|
||||
|
||||
+3
-5
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user