优化首页和底部导航栏性能
This commit is contained in:
1 parent
ff2724a302
commit
0a260a47c6
4 files changed
+140
-53
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,12 +206,17 @@ private fun HeroCarousel(
|
||||
onItemClick: (EmbyItem) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(pagerState, items.size) {
|
||||
while (true) {
|
||||
delay(5000)
|
||||
if (items.isEmpty()) break
|
||||
val next = (pagerState.currentPage + 1) % items.size
|
||||
pagerState.animateScrollToPage(next)
|
||||
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 (!pagerState.isScrollInProgress) {
|
||||
val next = (pagerState.currentPage + 1) % items.size
|
||||
pagerState.animateScrollToPage(next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
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,30 +996,42 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (libraries.isNotEmpty()) {
|
||||
if (libraries.isNotEmpty()) {
|
||||
item(key = "media", contentType = "media") {
|
||||
HomeSection(first = resumeItems.isEmpty()) {
|
||||
MyMediaGrid(libraries, server.fullUrl) { folder ->
|
||||
// Handle selection
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
libraries.forEach { folder ->
|
||||
LazyLibrarySection(
|
||||
folder = folder,
|
||||
activeServer = server,
|
||||
refreshToken = reloadToken,
|
||||
onItemClick = { onOpenDetail(it.id) },
|
||||
onViewAll = { onViewAll(server.id, folder.id, folder.name) },
|
||||
)
|
||||
}
|
||||
items(libraries, key = { "library_${it.id}" }, contentType = { "library" }) { folder ->
|
||||
HomeSection {
|
||||
LazyLibrarySection(
|
||||
folder = folder,
|
||||
activeServer = server,
|
||||
refreshToken = reloadToken,
|
||||
onItemClick = { onOpenDetail(it.id) },
|
||||
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 palette = Palette.from(bitmap).maximumColorCount(32).generate()
|
||||
val sourceArgb = selectRepresentativeColor(palette)
|
||||
extracted = deriveAmbientPalette(Color(sourceArgb), themeDark)
|
||||
val result = withContext(Dispatchers.Default) {
|
||||
val palette = Palette.from(bitmap).maximumColorCount(32).generate()
|
||||
val sourceArgb = selectRepresentativeColor(palette)
|
||||
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(
|
||||
|
||||
Reference in new issue
Block a user