优化首页和底部导航栏性能

This commit is contained in:
beyond committed 2026-09-05 21:30:08 +08:00
1 parent ff2724a302
commit 0a260a47c6
4 files changed
+124 -37

No files matched your search

@@ -63,6 +63,9 @@ fun AnimatedContentTransitionScope<NavBackStackEntry>.appExitTransition(): ExitT
}
fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopEnterTransition(): EnterTransition {
if (isRootRoute(initialState.destination.route) && isRootRoute(targetState.destination.route)) {
return appEnterTransition()
}
if (isReturningFromSettingsChild()) return EnterTransition.None
return slideInHorizontally(
animationSpec = tween(NavigationMotionTokens.detailPopDuration, easing = NavigationMotionTokens.popEasing),
@@ -71,6 +74,9 @@ fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopEnterTransition(): E
}
fun AnimatedContentTransitionScope<NavBackStackEntry>.appPopExitTransition(): ExitTransition {
if (isRootRoute(initialState.destination.route) && isRootRoute(targetState.destination.route)) {
return appExitTransition()
}
if (isReturningFromSettingsChild()) return ExitTransition.None
return slideOutHorizontally(
animationSpec = tween(NavigationMotionTokens.detailPopDuration, easing = NavigationMotionTokens.popEasing),
@@ -99,6 +99,10 @@ 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 androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
@@ -113,14 +117,14 @@ val LocalHomeContentColor = staticCompositionLocalOf { Color.Unspecified }
val LocalHomeSecondaryColor = staticCompositionLocalOf { Color.Unspecified }
val LocalHomeAccentColor = staticCompositionLocalOf { Color.Unspecified }
private data class HomeSnapshot(
internal data class HomeSnapshot(
val libraries: List<EmbyMediaFolder>,
val resumeItems: List<EmbyItem>,
val latestItems: List<EmbyItem>,
val resourceStats: EmbyResourceStats,
)
private object HomeContentCache {
internal object HomeContentCache {
private val snapshots = mutableMapOf<String, HomeSnapshot>()
private val libraryPages = mutableMapOf<String, EmbyLibraryPage>()
private val episodeCounts = mutableMapOf<String, Int>() // cacheKey + seriesId → total episode count
@@ -202,14 +206,19 @@ private fun HeroCarousel(
onItemClick: (EmbyItem) -> Unit = {},
) {
val context = LocalContext.current
LaunchedEffect(pagerState, items.size) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(pagerState, items.size, lifecycleOwner) {
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
if (items.size < 2) return@repeatOnLifecycle
while (true) {
delay(5000)
if (items.isEmpty()) break
if (!pagerState.isScrollInProgress) {
val next = (pagerState.currentPage + 1) % items.size
pagerState.animateScrollToPage(next)
}
}
}
}
val nextItem = items.getOrNull((pagerState.currentPage + 1) % items.size.coerceAtLeast(1))
LaunchedEffect(nextItem?.id, serverUrl) {
@@ -519,13 +528,19 @@ private fun LazyLibrarySection(
mutableStateOf(HomeContentCache.libraryPage(cacheKey, folder.id))
}
var episodeCounts by remember(cacheKey, folder.id, refreshToken) {
mutableStateOf<Map<String, Int>>(emptyMap())
mutableStateOf(
page?.items.orEmpty().mapNotNull { item ->
HomeContentCache.episodeCount(cacheKey, item.id)?.let { item.id to it }
}.toMap(),
)
}
LaunchedEffect(cacheKey, folder.id, refreshToken) {
if (page == null) {
page = try {
EmbyRepository.getItemsForLibrary(activeServer, folder.id, limit = 8)
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
EmbyLibraryPage()
}.also { loaded ->
@@ -541,6 +556,8 @@ private fun LazyLibrarySection(
val cached = HomeContentCache.episodeCount(cacheKey, item.id)
val count = cached ?: try {
EmbyRepository.getSeriesEpisodeCount(activeServer, item.id)
} catch (error: CancellationException) {
throw error
} catch (_: Exception) { 0 }
if (cached == null) HomeContentCache.putEpisodeCount(cacheKey, item.id, count)
counts[item.id] = count
@@ -565,7 +582,7 @@ private fun LazyLibrarySection(
}
} else if (list.isNotEmpty()) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(list) { item ->
items(list, key = { it.id }) { item ->
MediaPoster(
item = item,
serverUrl = activeServer.fullUrl,
@@ -790,11 +807,14 @@ fun HomeScreen(
val activeServer = remember(servers, selected) { selected ?: servers.firstOrNull { it.apiKey.isNotBlank() } }
val homeKey = activeServer?.let(::homeCacheKey)
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) }
// Restore before the first composition so returning does not replay loading
// and rebuild the entire page again during the navigation transition.
val initialSnapshot = remember(homeKey) { homeKey?.let(HomeContentCache::snapshot) }
var libraries by remember(homeKey) { mutableStateOf(initialSnapshot?.libraries.orEmpty()) }
var resumeItems by remember(homeKey) { mutableStateOf(initialSnapshot?.resumeItems.orEmpty()) }
var latestItems by remember(homeKey) { mutableStateOf(initialSnapshot?.latestItems.orEmpty()) }
var resourceStats by remember(homeKey) { mutableStateOf(initialSnapshot?.resourceStats ?: EmbyResourceStats()) }
var loading by remember(homeKey) { mutableStateOf(activeServer != null && initialSnapshot == null) }
var loadError by remember(homeKey) { mutableStateOf(false) }
var refreshing by remember(homeKey) { mutableStateOf(false) }
var reloadToken by remember(homeKey) { mutableIntStateOf(0) }
@@ -828,19 +848,7 @@ fun HomeScreen(
if (server == null || server.apiKey.isBlank() || cacheKey == null) return@LaunchedEffect
val inPlaceRefresh = reloadToken > 0 && hasHomeContent
if (reloadToken == 0) {
HomeContentCache.snapshot(cacheKey)?.let { snapshot ->
libraries = snapshot.libraries
latestItems = snapshot.latestItems
resumeItems = snapshot.resumeItems
resourceStats = snapshot.resourceStats
loadError = false
loading = false
return@LaunchedEffect
}
} else {
HomeContentCache.invalidate(cacheKey)
}
if (reloadToken == 0 && initialSnapshot != null) return@LaunchedEffect
if (inPlaceRefresh) refreshing = true else loading = true
try {
@@ -864,6 +872,8 @@ fun HomeScreen(
),
)
loadError = false
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
loadError = true
} finally {
@@ -960,7 +970,7 @@ fun HomeScreen(
contentPadding = PaddingValues(bottom = bottomBarContentPadding().calculateBottomPadding())
) {
if (heroItems.isNotEmpty()) {
item {
item(key = "hero", contentType = "hero") {
HeroCarousel(
items = heroItems,
serverUrl = server.fullUrl,
@@ -972,18 +982,13 @@ fun HomeScreen(
}
}
item {
Column(
Modifier
.fillMaxWidth()
.padding(start = LumaSpacing.md, end = LumaSpacing.md, top = LumaSpacing.md),
verticalArrangement = Arrangement.spacedBy(LumaSpacing.xl),
) {
if (resumeItems.isNotEmpty()) {
item(key = "resume", contentType = "resume") {
HomeSection(first = true) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SectionTitle(title = stringResource(Res.string.continue_watching))
LazyRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
items(resumeItems) { item ->
items(resumeItems, key = { it.id }) { item ->
ContinueWatchingPoster(item, server.fullUrl) {
onOpenDetail(continueWatchingDetailId(it))
}
@@ -991,14 +996,21 @@ fun HomeScreen(
}
}
}
}
}
if (libraries.isNotEmpty()) {
item(key = "media", contentType = "media") {
HomeSection(first = resumeItems.isEmpty()) {
MyMediaGrid(libraries, server.fullUrl) { folder ->
// Handle selection
}
}
}
}
libraries.forEach { folder ->
items(libraries, key = { "library_${it.id}" }, contentType = { "library" }) { folder ->
HomeSection {
LazyLibrarySection(
folder = folder,
activeServer = server,
@@ -1007,14 +1019,19 @@ fun HomeScreen(
onViewAll = { onViewAll(server.id, folder.id, folder.name) },
)
}
}
item(key = "stats", contentType = "stats") {
HomeSection(first = resumeItems.isEmpty() && libraries.isEmpty()) {
ResourceStatsCard(
stats = resourceStats,
onClick = { showResourceStats = true },
)
Spacer(Modifier.height(LumaSpacing.md))
}
}
item(key = "bottomSpacing", contentType = "spacing") {
Spacer(Modifier.height(LumaSpacing.xl + LumaSpacing.md))
}
}
}
}
@@ -1074,6 +1091,19 @@ fun HomeScreen(
}
}
@Composable
private fun HomeSection(first: Boolean = false, content: @Composable () -> Unit) {
Box(
Modifier.fillMaxWidth().padding(
start = LumaSpacing.md,
end = LumaSpacing.md,
top = if (first) LumaSpacing.md else LumaSpacing.xl,
),
) {
content()
}
}
@Composable
private fun HomeOverlayTopBar(
server: EmbyServer?,
@@ -1,6 +1,7 @@
package com.example.myapplication.ui
import android.graphics.drawable.BitmapDrawable
import android.util.LruCache
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
@@ -15,6 +16,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.palette.graphics.Palette
import coil.imageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.abs
data class PosterImmersivePalette(
@@ -24,6 +28,10 @@ data class PosterImmersivePalette(
val isDark: Boolean,
)
// Keep only derived colors, never bitmaps. Returning to a page can draw its
// final colors immediately without decoding or animating from the fallback.
private val posterPaletteCache = LruCache<Pair<String, Boolean>, PosterImmersivePalette>(32)
/**
* Extracts an ambient palette from a poster/backdrop while preserving the real
* artwork hue. Light theme produces a clean pastel version; dark theme keeps a
@@ -34,8 +42,9 @@ fun rememberPosterImmersiveBackground(
imageUrl: String?,
themeDark: Boolean = GlassTokens.isDark,
): PosterImmersivePalette {
val cacheKey = imageUrl?.takeIf { it.isNotBlank() }?.let { it to themeDark }
var extracted by remember(imageUrl, themeDark) {
mutableStateOf(defaultPosterPalette(themeDark))
mutableStateOf(cacheKey?.let(posterPaletteCache::get) ?: defaultPosterPalette(themeDark))
}
val context = LocalContext.current
@@ -44,6 +53,10 @@ fun rememberPosterImmersiveBackground(
extracted = defaultPosterPalette(themeDark)
return@LaunchedEffect
}
cacheKey?.let(posterPaletteCache::get)?.let {
extracted = it
return@LaunchedEffect
}
try {
val request = ImageRequest.Builder(context)
@@ -54,10 +67,16 @@ fun rememberPosterImmersiveBackground(
val bitmap = (context.imageLoader.execute(request).drawable as? BitmapDrawable)?.bitmap
if (bitmap != null && !bitmap.isRecycled) {
val result = withContext(Dispatchers.Default) {
val palette = Palette.from(bitmap).maximumColorCount(32).generate()
val sourceArgb = selectRepresentativeColor(palette)
extracted = deriveAmbientPalette(Color(sourceArgb), themeDark)
deriveAmbientPalette(Color(sourceArgb), themeDark)
}
if (cacheKey != null) posterPaletteCache.put(cacheKey, result)
extracted = result
}
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
extracted = defaultPosterPalette(themeDark)
}
@@ -1,11 +1,43 @@
package com.example.myapplication.screens
import com.example.myapplication.data.EmbyItem
import com.example.myapplication.data.EmbyLibraryPage
import com.example.myapplication.data.EmbyResourceStats
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class HomeScreenDataTest {
private val cacheKey = "home-test|server-a|user-a"
private val otherCacheKey = "home-test|server-b|user-b"
@After
fun clearSnapshots() {
HomeContentCache.invalidate(cacheKey)
HomeContentCache.invalidate(otherCacheKey)
}
@Test
fun refreshingHomeAlsoInvalidatesOffscreenLibrariesAndEpisodeCounts() {
val snapshot = HomeSnapshot(emptyList(), emptyList(), emptyList(), EmbyResourceStats())
for (key in listOf(cacheKey, otherCacheKey)) {
HomeContentCache.putSnapshot(key, snapshot)
HomeContentCache.putLibraryPage(key, "library", EmbyLibraryPage())
HomeContentCache.putEpisodeCount(key, "series", 12)
}
HomeContentCache.invalidate(cacheKey)
assertNull(HomeContentCache.snapshot(cacheKey))
assertNull(HomeContentCache.libraryPage(cacheKey, "library"))
assertNull(HomeContentCache.episodeCount(cacheKey, "series"))
assertSame(snapshot, HomeContentCache.snapshot(otherCacheKey))
assertEquals(EmbyLibraryPage(), HomeContentCache.libraryPage(otherCacheKey, "library"))
assertEquals(12, HomeContentCache.episodeCount(otherCacheKey, "series"))
}
@Test
fun heroTitleUsesSeriesNameForEpisodeFallback() {
val episode = EmbyItem(