From 0a260a47c69cc2b3419ad21fd190226485896d94 Mon Sep 17 00:00:00 2001 From: beyond Date: Sat, 5 Sep 2026 21:30:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A6=96=E9=A1=B5=E5=92=8C?= =?UTF-8?q?=E5=BA=95=E9=83=A8=E5=AF=BC=E8=88=AA=E6=A0=8F=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../navigation/NavigationTransitions.kt | 6 + .../myapplication/screens/HomeScreen.kt | 128 +++++++++++------- .../myapplication/ui/ImmersiveBackground.kt | 27 +++- .../screens/HomeScreenDataTest.kt | 32 +++++ 4 files changed, 140 insertions(+), 53 deletions(-) diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt b/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt index 41f5fa9..3baff7c 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/NavigationTransitions.kt @@ -63,6 +63,9 @@ fun AnimatedContentTransitionScope.appExitTransition(): ExitT } fun AnimatedContentTransitionScope.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.appPopEnterTransition(): E } fun AnimatedContentTransitionScope.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), diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index 5ad02cb..89dbdeb 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -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, val resumeItems: List, val latestItems: List, val resourceStats: EmbyResourceStats, ) -private object HomeContentCache { +internal object HomeContentCache { private val snapshots = mutableMapOf() private val libraryPages = mutableMapOf() private val episodeCounts = mutableMapOf() // 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>(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>(emptyList()) } - var resumeItems by remember(homeKey) { mutableStateOf>(emptyList()) } - var latestItems by remember(homeKey) { mutableStateOf>(emptyList()) } - var resourceStats by remember(homeKey) { mutableStateOf(EmbyResourceStats()) } - var loading by remember(homeKey) { mutableStateOf(activeServer != null) } + // 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?, diff --git a/app/src/main/kotlin/com/example/myapplication/ui/ImmersiveBackground.kt b/app/src/main/kotlin/com/example/myapplication/ui/ImmersiveBackground.kt index e404714..70ce390 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/ImmersiveBackground.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/ImmersiveBackground.kt @@ -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, 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) } diff --git a/app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt b/app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt index f6ea67c..c708f58 100644 --- a/app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt +++ b/app/src/test/kotlin/com/example/myapplication/screens/HomeScreenDataTest.kt @@ -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(