From 3670466a65f20dcee1a5e74a61827372ca99b73f Mon Sep 17 00:00:00 2001 From: beyond Date: Sun, 6 Sep 2026 13:30:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AC=AC=E4=B8=80=E8=BD=AE=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=20=E2=80=94=20R8=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E3=80=81=E5=8D=8F=E7=A8=8B=E6=B3=84=E6=BC=8F=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E3=80=81@Immutable=E6=B3=A8=E8=A7=A3=E3=80=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=8F=90=E5=8F=96=E3=80=81=E5=B9=B6=E8=A1=8C=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Release build 启用 R8/ProGuard + 新建 proguard-rules.pro - MediaDetailScreen: MainScope → rememberCoroutineScope - AppNavHost: openDetail/openHome lambda 记忆化 - VideoPlayerScreen: 提取 PlayerStatusBar 独立 Composable - LibraryScreen: nowMillis 下沉到 ServerStatusLine 内部 - 8个核心数据类添加 @Immutable 注解 - TmdbCalendar: coroutineScope+async 并行化 season 请求 - SearchScreen: derivedStateOf 正确观察 SnapshotStateList - HomeScreen: MediaPoster/ContinueWatchingPoster ImageRequest remember - MediaDetailScreen: cast/recommendations/resources LazyRow 添加 key - RecordsScreen: LaunchedEffect key 用 hashCode 替代 joinToString --- app/build.gradle.kts | 7 +- app/proguard-rules.pro | 63 +++++++ .../myapplication/data/EmbyApiModels.kt | 3 + .../example/myapplication/data/EmbyServer.kt | 5 + .../myapplication/data/TmdbRepository.kt | 157 ++++++++++-------- .../myapplication/navigation/AppNavHost.kt | 33 ++-- .../myapplication/screens/HomeScreen.kt | 24 ++- .../myapplication/screens/LibraryScreen.kt | 30 ++-- .../screens/MediaDetailScreen.kt | 17 +- .../myapplication/screens/RecordsScreen.kt | 5 +- .../myapplication/screens/SearchScreen.kt | 15 +- .../screens/VideoPlayerScreen.kt | 90 ++++++---- .../com/example/myapplication/ui/AppTheme.kt | 2 + .../myapplication/ui/ImmersiveBackground.kt | 2 + .../example/myapplication/ui/LiquidGlass.kt | 2 + .../ui/design/LumaDesignSystem.kt | 5 + 16 files changed, 309 insertions(+), 151 deletions(-) create mode 100644 app/proguard-rules.pro diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c1ce27..af47c1d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,7 +30,12 @@ android { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..77f749d --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,63 @@ +# ── Room entities & DAOs (reflection-based by Room) ── +-keep class com.example.myapplication.data.ServerEntity { *; } +-keep class com.example.myapplication.data.FavoriteEntity { *; } +-keep class com.example.myapplication.data.WatchlistEntity { *; } +-keep class com.example.myapplication.data.IconLibraryEntity { *; } +-keep class com.example.myapplication.data.*Dao { *; } +-keep class com.example.myapplication.data.AppDatabase { *; } + +# ── Gson / Retrofit (keep serialized field names) ── +-keepattributes Signature +-keepattributes *Annotation* +-dontwarn sun.misc.** +-keep class com.google.gson.** { *; } +-keep class retrofit2.** { *; } +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} +-keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken +-keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken + +# ── Keep Emby API models (Gson deserialization) ── +-keep class com.example.myapplication.data.EmbyItem { *; } +-keep class com.example.myapplication.data.EmbyServer { *; } +-keep class com.example.myapplication.data.EmbyPerson { *; } +-keep class com.example.myapplication.data.EmbyMediaStream { *; } +-keep class com.example.myapplication.data.EmbyMediaSource { *; } +-keep class com.example.myapplication.data.EmbyChapter { *; } +-keep class com.example.myapplication.data.EmbyUserData { *; } +-keep class com.example.myapplication.data.EmbyNamedReference { *; } +-keep class com.example.myapplication.data.EmbyAuthRequest { *; } +-keep class com.example.myapplication.data.EmbyAuthResponse { *; } +-keep class com.example.myapplication.data.EmbyUser { *; } +-keep class com.example.myapplication.data.EmbyMediaFoldersResponse { *; } +-keep class com.example.myapplication.data.EmbyMediaFolder { *; } +-keep class com.example.myapplication.data.EmbyItemsResponse { *; } +-keep class com.example.myapplication.data.EmbyPlaybackInfoResponse { *; } +-keep class com.example.myapplication.data.EmbyResourceStats { *; } + +# ── OkHttp ── +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } + +# ── OkHttp Logging Interceptor ── +-dontwarn okhttp3.logging.** + +# ── Coil ── +-dontwarn coil.** + +# ── Media3 / ExoPlayer ── +-keep class androidx.media3.** { *; } +-dontwarn androidx.media3.** + +# ── Kyant Backdrop ── +-keep class com.kyant.backdrop.** { *; } +-dontwarn com.kyant.backdrop.** + +# ── Miuix ── +-keep class top.yukonga.miuix.** { *; } +-dontwarn top.yukonga.miuix.** + +# ── Palette ── +-keep class androidx.palette.** { *; } diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt index 6f14d00..c72475c 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyApiModels.kt @@ -1,5 +1,6 @@ package com.example.myapplication.data +import androidx.compose.runtime.Immutable import com.google.gson.annotations.SerializedName /* ---------- Auth ---------- */ @@ -27,6 +28,7 @@ data class EmbyMediaFoldersResponse( @SerializedName("TotalRecordCount") val totalRecordCount: Int = 0, ) +@Immutable data class EmbyMediaFolder( @SerializedName("Id") val id: String, @SerializedName("Name") val name: String, @@ -42,6 +44,7 @@ data class EmbyItemsResponse( @SerializedName("StartIndex") val startIndex: Int = 0, ) +@Immutable data class EmbyItem( @SerializedName("Id") val id: String, @SerializedName("Name") val name: String, diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt index c78bc3d..a1100dc 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyServer.kt @@ -1,10 +1,12 @@ package com.example.myapplication.data import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable import com.example.myapplication.R import java.net.URI import java.util.UUID +@Immutable data class EmbyLibrary( val id: String, val name: String, @@ -12,6 +14,7 @@ data class EmbyLibrary( val enabled: Boolean = true, ) +@Immutable enum class LibraryType(@StringRes val labelRes: Int) { MOVIES(R.string.library_movies), TV_SHOWS(R.string.library_tv_shows), @@ -21,6 +24,7 @@ enum class LibraryType(@StringRes val labelRes: Int) { PHOTOS(R.string.library_photos), } +@Immutable data class EmbyServer( val id: String, val name: String = "", @@ -62,6 +66,7 @@ data class EmbyServer( } } +@Immutable data class EmbyServerAddress( val id: String = generateId(), val name: String = "", diff --git a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt index ae62b6f..b39f201 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt @@ -8,6 +8,7 @@ import com.google.gson.Gson import com.google.gson.annotations.SerializedName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -322,43 +323,68 @@ object TmdbRepository { /** Get calendar episodes for all watchlist items. */ suspend fun getCalendarEpisodes(watchlist: List): List = withContext(Dispatchers.IO) { if (!isConfigured || watchlist.isEmpty()) return@withContext emptyList() - val results = mutableListOf() - watchlist.forEach { entity -> - val tmdbId = entity.tmdbId?.toIntOrNull() ?: return@forEach - try { - val seriesDetail = get( - "$API_BASE/tv/$tmdbId?language=${language()}&append_to_response=images&include_image_language=zh,null,en", - TmdbTvDetailResponse::class.java, - ) - ?: return@forEach - val seriesName = seriesDetail.name.ifBlank { entity.name } - val posterUrl = seriesDetail.posterPath?.let { IMAGE_BASE + it } ?: entity.posterUrl - val genres = seriesDetail.genres.joinToString(", ") { it.name } - val firstAirYear = seriesDetail.firstAirDate.take(4) - val networkName = seriesDetail.networks.firstOrNull()?.name ?: "" - val networkLogo = seriesDetail.networks.firstOrNull()?.logoPath?.let { IMAGE_BASE.replace("w342", "w154") + it } - val logoPath = seriesDetail.images.logos - .sortedBy { - when (it.language) { - "zh" -> 0 - "en" -> 1 - null -> 2 - else -> 3 - } - } - .firstOrNull { !it.filePath.isNullOrBlank() } - ?.filePath - ?: seriesDetail.logoPath - val logoUrl = logoPath?.let { IMAGE_BASE.replace("w342", "w300") + it } - // Load all regular seasons so the calendar can also show past episodes. + // P1: Parallelize — fetch each series concurrently, then join results + val seriesResults = coroutineScope { + watchlist.mapNotNull { entity -> + val tmdbId = entity.tmdbId?.toIntOrNull() ?: return@mapNotNull null + async { + fetchSeriesCalendarEpisodes(tmdbId, entity) + } + }.awaitAll() + } + + seriesResults.flatten().sortedBy { it.airDate } + } + + private suspend fun fetchSeriesCalendarEpisodes( + tmdbId: Int, + entity: WatchlistEntity, + ): List { + val results = mutableListOf() + try { + val seriesDetail = get( + "$API_BASE/tv/$tmdbId?language=${language()}&append_to_response=images&include_image_language=zh,null,en", + TmdbTvDetailResponse::class.java, + ) ?: return emptyList() + val seriesName = seriesDetail.name.ifBlank { entity.name } + val posterUrl = seriesDetail.posterPath?.let { IMAGE_BASE + it } ?: entity.posterUrl + val genres = seriesDetail.genres.joinToString(", ") { it.name } + val firstAirYear = seriesDetail.firstAirDate.take(4) + val networkName = seriesDetail.networks.firstOrNull()?.name ?: "" + val networkLogo = seriesDetail.networks.firstOrNull()?.logoPath?.let { IMAGE_BASE.replace("w342", "w154") + it } + val logoPath = seriesDetail.images.logos + .sortedBy { + when (it.language) { + "zh" -> 0 + "en" -> 1 + null -> 2 + else -> 3 + } + } + .firstOrNull { !it.filePath.isNullOrBlank() } + ?.filePath + ?: seriesDetail.logoPath + val logoUrl = logoPath?.let { IMAGE_BASE.replace("w342", "w300") + it } + + // P1: Parallelize season requests within each series + val seasonEpisodes = coroutineScope { seriesDetail.seasons .filter { it.seasonNumber > 0 && it.episodeCount > 0 } - .forEach { season -> - val seasonDetail = get( - "$API_BASE/tv/$tmdbId/season/${season.seasonNumber}?language=${language()}", - TmdbSeasonDetailResponse::class.java, - ) + .map { season -> + async { + get( + "$API_BASE/tv/$tmdbId/season/${season.seasonNumber}?language=${language()}", + TmdbSeasonDetailResponse::class.java, + ) + } + }.awaitAll() + } + + seriesDetail.seasons + .filter { it.seasonNumber > 0 && it.episodeCount > 0 } + .zip(seasonEpisodes) + .forEach { (season, seasonDetail) -> val episodes = seasonDetail?.episodes.orEmpty() val totalInSeason = episodes.size.takeIf { it > 0 } ?: season.episodeCount episodes.forEach { ep -> @@ -384,41 +410,40 @@ object TmdbRepository { } } - // Keep a next episode if TMDB exposes it before the season endpoint does. - seriesDetail.nextEpisodeToAir?.let { next -> - val isDuplicate = results.any { - it.tmdbId == tmdbId && - it.seasonNumber == next.seasonNumber && - it.episodeNumber == next.episodeNumber - } - if (!isDuplicate && next.airDate.isNotBlank()) { - val totalInSeason = seriesDetail.seasons - .firstOrNull { it.seasonNumber == next.seasonNumber } - ?.episodeCount ?: 0 - results.add(TmdbEpisodeCalendar( - tmdbId = tmdbId, - seriesName = seriesName, - seasonNumber = next.seasonNumber, - episodeNumber = next.episodeNumber, - episodeName = next.name, - airDate = next.airDate, - overview = next.overview, - stillPath = next.stillPath, - posterUrl = posterUrl, - totalEpisodes = totalInSeason, - genres = genres, - firstAirYear = firstAirYear, - networkName = networkName, - networkLogoPath = networkLogo, - logoPath = logoUrl, - )) - } + // Keep a next episode if TMDB exposes it before the season endpoint does. + seriesDetail.nextEpisodeToAir?.let { next -> + val isDuplicate = results.any { + it.tmdbId == tmdbId && + it.seasonNumber == next.seasonNumber && + it.episodeNumber == next.episodeNumber + } + if (!isDuplicate && next.airDate.isNotBlank()) { + val totalInSeason = seriesDetail.seasons + .firstOrNull { it.seasonNumber == next.seasonNumber } + ?.episodeCount ?: 0 + results.add(TmdbEpisodeCalendar( + tmdbId = tmdbId, + seriesName = seriesName, + seasonNumber = next.seasonNumber, + episodeNumber = next.episodeNumber, + episodeName = next.name, + airDate = next.airDate, + overview = next.overview, + stillPath = next.stillPath, + posterUrl = posterUrl, + totalEpisodes = totalInSeason, + genres = genres, + firstAirYear = firstAirYear, + networkName = networkName, + networkLogoPath = networkLogo, + logoPath = logoUrl, + )) } - } catch (e: Exception) { - Log.w("TmdbRepository", "Failed to get calendar for ${entity.name}", e) } + } catch (e: Exception) { + Log.w("TmdbRepository", "Failed to get calendar for ${entity.name}", e) } - results.sortedBy { it.airDate } + return results } private fun get(url: String, type: Class): T? { diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt index 587f529..768c7ca 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -81,25 +81,30 @@ fun AppNavHost( navController.navigate(route) { launchSingleTop = true } } - val openDetail: (String, String) -> Unit = { serverId, itemId -> - if (serverId.isNotBlank() && itemId.isNotBlank()) { - val currentEntry = navController.currentBackStackEntry - val isAlreadyOnThisDetail = currentEntry?.destination?.route == AppRoute.MediaDetail.pattern && - currentEntry.arguments?.getString("itemId") == itemId - if (!isAlreadyOnThisDetail) { - navController.navigate(AppRoute.MediaDetail.create(serverId, itemId)) { - launchSingleTop = true + // P0: Remember navigation lambdas to prevent unnecessary child recomposition + val openDetail: (String, String) -> Unit = remember(navController) { + { serverId, itemId -> + if (serverId.isNotBlank() && itemId.isNotBlank()) { + val currentEntry = navController.currentBackStackEntry + val isAlreadyOnThisDetail = currentEntry?.destination?.route == AppRoute.MediaDetail.pattern && + currentEntry.arguments?.getString("itemId") == itemId + if (!isAlreadyOnThisDetail) { + navController.navigate(AppRoute.MediaDetail.create(serverId, itemId)) { + launchSingleTop = true + } } } } } - val openHome: () -> Unit = { - if (navController.currentDestination?.route != AppRoute.Home.pattern) { - navController.navigate(AppRoute.Home.pattern) { - popUpTo(AppRoute.Home.pattern) { saveState = true } - launchSingleTop = true - restoreState = true + val openHome: () -> Unit = remember(navController) { + { + if (navController.currentDestination?.route != AppRoute.Home.pattern) { + navController.navigate(AppRoute.Home.pattern) { + popUpTo(AppRoute.Home.pattern) { saveState = true } + launchSingleTop = true + restoreState = true + } } } } 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 1748fb1..e769610 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -423,11 +423,16 @@ private fun MediaPoster( episodeCount: Int = 0, onItemClick: (EmbyItem) -> Unit = {}, ) { - val posterUrl = EmbyRepository.buildImageUrl(serverUrl, item) + // P2: Remember ImageRequest to avoid allocation on every recomposition + val context = LocalContext.current + val posterUrl = remember(item.id, serverUrl) { EmbyRepository.buildImageUrl(serverUrl, item) } + val imageRequest = remember(posterUrl) { + posterUrl?.let { + ImageRequest.Builder(context).data(it).crossfade(true).build() + } + } LumaMediaCard( - model = posterUrl?.let { - ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build() - }, + model = imageRequest, title = item.seriesName ?: item.name, modifier = modifier.width(115.dp), imageAspectRatio = 115f / 165f, @@ -470,10 +475,15 @@ private fun ContinueWatchingPoster(item: EmbyItem, serverUrl: String, onItemClic .coerceIn(0f, 1f) } val elapsed = formatPlaybackElapsed(item.playbackPositionMs) + // P2: Remember ImageRequest to avoid allocation on every recomposition + val context = LocalContext.current + val imageRequest = remember(imageUrl) { + imageUrl?.let { + ImageRequest.Builder(context).data(it).crossfade(true).build() + } + } LumaMediaCard( - model = imageUrl?.let { - ImageRequest.Builder(LocalContext.current).data(it).crossfade(true).build() - }, + model = imageRequest, title = item.seriesName ?: item.name, modifier = Modifier.width(220.dp), imageAspectRatio = 16f / 9f, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt index 54aa8ed..872fb0b 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -169,7 +169,7 @@ fun LibraryTopBar( /* ------------------------------------------------------------------ */ @Composable -private fun ServerCard(server: EmbyServer, tint: Color, nowMillis: Long, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { +private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { var clicked by remember { mutableStateOf(false) } val activeLineName = server.activeAlternateAddress?.name?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.server_primary_line) @@ -201,7 +201,7 @@ private fun ServerCard(server: EmbyServer, tint: Color, nowMillis: Long, onClick Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text(text = server.name.ifBlank { server.displayHost }, color = GlassTokens.text, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(0.85f)) - ServerStatusLine(server = server, nowMillis = nowMillis, compact = true) + ServerStatusLine(server = server, compact = true) } Row(Modifier.padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically) { ServerMediaCounts(server = server) @@ -234,7 +234,7 @@ private fun ServerCard(server: EmbyServer, tint: Color, nowMillis: Long, onClick /* ------------------------------------------------------------------ */ @Composable -private fun ServerGridCard(server: EmbyServer, tint: Color, nowMillis: Long, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { +private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onChangeIcon: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, onMorePositioned: (LayoutCoordinates) -> Unit, onMoreClick: () -> Unit) { var clicked by remember { mutableStateOf(false) } val activeLineName = server.activeAlternateAddress?.name?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.server_primary_line) @@ -297,7 +297,7 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, nowMillis: Long, onC LumaIconView(LumaIcon.More, contentDescription = stringResource(Res.string.server_actions), tint = GlassTokens.subtext, modifier = Modifier.size(18.dp)) } } - ServerStatusLine(server = server, nowMillis = nowMillis, compact = true, modifier = Modifier.padding(top = 6.dp)) + ServerStatusLine(server = server, compact = true, modifier = Modifier.padding(top = 6.dp)) if (!server.isOnline && server.authError.isNotBlank()) { Text(text = server.authError, color = offlineRed, fontSize = 9.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp)) } else if (server.note.isNotBlank()) { @@ -338,16 +338,8 @@ fun LibraryScreen( var deleteServerTarget by remember { mutableStateOf(null) } var addressPickerTarget by remember { mutableStateOf(null) } var rootOrigin by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) } - var nowMillis by remember { mutableStateOf(System.currentTimeMillis()) } val libraryBackdrop = rememberLumaLayerBackdrop() - LaunchedEffect(Unit) { - while (true) { - delay(60_000L) - nowMillis = System.currentTimeMillis() - } - } - if (onChangeIconTarget != null) { IconPickerDialog( server = onChangeIconTarget, @@ -386,7 +378,7 @@ fun LibraryScreen( val tint = serverTints[index % serverTints.size] if (isGrid) { ServerGridCard( - server = server, tint = tint, nowMillis = nowMillis, + server = server, tint = tint, onClick = { onSelectServer(server.id) }, onChangeIcon = { onRequestChangeIcon(server) }, onDelete = { onDeleteServer(server.id) }, @@ -402,7 +394,7 @@ fun LibraryScreen( ) } else { ServerCard( - server = server, tint = tint, nowMillis = nowMillis, + server = server, tint = tint, onClick = { onSelectServer(server.id) }, onChangeIcon = { onRequestChangeIcon(server) }, onDelete = { onDeleteServer(server.id) }, @@ -565,7 +557,15 @@ private fun ServerAddressPickerDialog( } @Composable -private fun ServerStatusLine(server: EmbyServer, nowMillis: Long, compact: Boolean, modifier: Modifier = Modifier) { +private fun ServerStatusLine(server: EmbyServer, compact: Boolean, modifier: Modifier = Modifier) { + // P2: Timer scoped to this composable only — avoids recomposing entire LibraryScreen + var nowMillis by remember { mutableStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (true) { + delay(60_000L) + nowMillis = System.currentTimeMillis() + } + } val elapsed = (nowMillis - server.lastAccessedAt).coerceAtLeast(0L) val accessText = when { server.lastAccessedAt <= 0L -> null diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt index 9e8fc1a..77c097f 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -97,12 +97,12 @@ import androidx.compose.ui.window.DialogProperties import coil.compose.AsyncImage import top.yukonga.miuix.kmp.basic.Text import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.MainScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import java.util.Locale +import androidx.compose.runtime.rememberCoroutineScope private val LocalDetailText = staticCompositionLocalOf { Color.White } private val LocalDetailSecondary = staticCompositionLocalOf { Color.White } @@ -167,6 +167,7 @@ fun MediaDetailScreen( var isFavorited by remember(item.id, server?.id) { mutableStateOf(false) } var isWatching by remember(item.id, server?.id) { mutableStateOf(false) } val episodes = selectedSeasonId?.let(episodesBySeason::get).orEmpty() + val detailCoroutineScope = rememberCoroutineScope() LaunchedEffect(item.id, server?.id, server?.apiKey, refreshToken) { val currentServer = server @@ -310,11 +311,13 @@ fun MediaDetailScreen( seriesPlayTarget ?: selectedPlayableItem } + // P1: Use stable hash key instead of string mapping on every recomposition + val serversStateKey = remember(servers) { servers.map { "${it.id}:${it.isOnline}" }.toSet().hashCode() } LaunchedEffect( selectedPlayableItem?.id, selectedSeasonId, server?.id, - servers.map { "${it.id}:${it.apiKey}:${it.isOnline}" }, + serversStateKey, refreshToken, ) { val currentServer = server ?: return@LaunchedEffect @@ -543,7 +546,7 @@ fun MediaDetailScreen( onClick = { val sId = server?.id if (sId != null) { - MainScope().launch { + detailCoroutineScope.launch { isFavorited = FavoriteRepository.toggleFavorite(sId, detail) } } @@ -558,7 +561,7 @@ fun MediaDetailScreen( onClick = { val sId = server?.id if (sId != null) { - MainScope().launch { + detailCoroutineScope.launch { isWatching = WatchlistRepository.toggleWatchlist(sId, detail) } } @@ -1169,7 +1172,7 @@ private fun ResourceSection( val spacing = 9.dp val cardWidth = (maxWidth - spacing) * 0.54f LazyRow(horizontalArrangement = Arrangement.spacedBy(spacing)) { - items(resources.size) { index -> + items(resources.size, key = { "${resources[it].server.id}_${resources[it].item.id}" }) { index -> val resource = resources[index] ResourceCard( resource = resource, @@ -1370,7 +1373,7 @@ private fun CastSection(cast: List, loading: Boolean) { return } LazyRow(horizontalArrangement = Arrangement.spacedBy(14.dp)) { - items(cast.size) { index -> CastMemberCard(cast[index]) } + items(cast.size, key = { cast[it].name }) { index -> CastMemberCard(cast[index]) } } } @@ -1470,7 +1473,7 @@ private fun RecommendationSection(recommendations: List, loa return } LazyRow(horizontalArrangement = Arrangement.spacedBy(11.dp)) { - items(recommendations.size) { index -> RecommendationCard(recommendations[index]) } + items(recommendations.size, key = { recommendations[it].id }) { index -> RecommendationCard(recommendations[index]) } } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index 7d2de1a..4a43a35 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -142,8 +142,9 @@ fun RecordsScreen( ) { var allFavorites by remember { mutableStateOf>(emptyList()) } - // Load ALL favorites from Room — show data from every server - LaunchedEffect(servers.map { it.id }.joinToString(",")) { + // P1: Use remember-based stable key instead of joining strings on every recomposition + val serverIdsKey = remember(servers) { servers.map { it.id }.toSet().hashCode() } + LaunchedEffect(serverIdsKey) { allFavorites = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { FavoriteRepository.getAllFavoritesFromDb() } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt index 54c535d..b08a799 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LinearProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf @@ -305,15 +306,15 @@ fun SearchScreen( if (query.isBlank()) loadSearchHistory(context) else emptyList() } - // Derived states for UI rendering - val hasAnyResults = remember(completedResults) { completedResults.any { it.items.isNotEmpty() } } - val hasAnyErrors = remember(completedResults) { completedResults.any { it.error } } - val allEmpty = remember(completedResults, searching) { + // P2: Use derivedStateOf for SnapshotStateList observation — correct reactive derivation + val hasAnyResults by remember { derivedStateOf { completedResults.any { it.items.isNotEmpty() } } } + val hasAnyErrors by remember { derivedStateOf { completedResults.any { it.error } } } + val allEmpty by remember { derivedStateOf { !searching && completedResults.isNotEmpty() && completedResults.all { it.items.isEmpty() && !it.error } - } - val allErrors = remember(completedResults, searching) { + } } + val allErrors by remember { derivedStateOf { !searching && completedResults.isNotEmpty() && completedResults.all { it.error } - } + } } Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt index 59af63f..26cebd7 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt @@ -335,7 +335,6 @@ fun VideoPlayerScreen( var networkSpeed by remember { mutableLongStateOf(0L) } var batteryPercent by remember { mutableIntStateOf(-1) } var isCharging by remember { mutableStateOf(false) } - var topTimeText by remember { mutableStateOf("") } var isWifi by remember { mutableStateOf(false) } var videoCodecText by remember { mutableStateOf("") } var videoBitrateText by remember { mutableStateOf("") } @@ -407,13 +406,6 @@ fun VideoPlayerScreen( } } - LaunchedEffect(Unit) { - while (true) { - topTimeText = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()).format(java.util.Date()) - delay(1000) - } - } - LaunchedEffect(Unit) { var batteryManager: BatteryManager? = null while (true) { @@ -918,30 +910,14 @@ fun VideoPlayerScreen( } Column(Modifier.fillMaxWidth().statusBarsPadding().padding(top = 16.dp)) { - // 第一排:当前时间(左)|网速 + 电量(右) - Row( - Modifier.fillMaxWidth().padding(horizontal = capsuleInset), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(topTimeText, color = Color.White, fontSize = 13.sp) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { - if (playbackUrl != null) { - Row(verticalAlignment = Alignment.CenterVertically) { - LumaIconView(if (isWifi) LumaIcon.Wifi else LumaIcon.Signal, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(4.dp)) - Text(formatNetworkSpeed(networkSpeed), color = Color.White, fontSize = 13.sp) - } - } - if (batteryPercent >= 0) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("$batteryPercent%", color = Color.White, fontSize = 13.sp) - Spacer(Modifier.width(4.dp)) - BatteryIcon(level = batteryPercent, charging = isCharging, modifier = Modifier.size(22.dp, 12.dp)) - } - } - } - } + // P0: Delegated to extracted composable — only this component polls and recomposes + PlayerStatusBar( + playbackUrl = playbackUrl, + networkSpeed = networkSpeed, + isWifi = isWifi, + batteryPercent = batteryPercent, + isCharging = isCharging, + ) Spacer(Modifier.height(8.dp)) // 第二排:关闭按钮(左)|Emby 艺术字(紧跟关闭按钮右侧)|字幕/剧集按钮(右) Row( @@ -1662,6 +1638,56 @@ private fun LiquidMenuItem(text: String, selected: Boolean, onClick: () -> Unit) } } +// P0: Extract status bar into its own composable so polling states (time/wifi/battery/speed) +// only recompose this small component instead of the entire VideoPlayerScreen. +@Composable +private fun PlayerStatusBar( + playbackUrl: String?, + networkSpeed: Long, + isWifi: Boolean, + batteryPercent: Int, + isCharging: Boolean, + capsuleInset: Dp = 18.dp, +) { + val topTimeText = remember { mutableStateOf("") } + val localNetworkSpeed by rememberUpdatedState(networkSpeed) + val localIsWifi by rememberUpdatedState(isWifi) + val localBatteryPercent by rememberUpdatedState(batteryPercent) + val localIsCharging by rememberUpdatedState(isCharging) + + // Clock update — only this composable recomposes + LaunchedEffect(Unit) { + while (true) { + topTimeText.value = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()).format(java.util.Date()) + delay(1000) + } + } + + Row( + Modifier.fillMaxWidth().padding(horizontal = capsuleInset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(topTimeText.value, color = Color.White, fontSize = 13.sp) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + if (playbackUrl != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + LumaIconView(if (localIsWifi) LumaIcon.Wifi else LumaIcon.Signal, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(formatNetworkSpeed(localNetworkSpeed), color = Color.White, fontSize = 13.sp) + } + } + if (localBatteryPercent >= 0) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("$localBatteryPercent%", color = Color.White, fontSize = 13.sp) + Spacer(Modifier.width(4.dp)) + BatteryIcon(level = localBatteryPercent, charging = localIsCharging, modifier = Modifier.size(22.dp, 12.dp)) + } + } + } + } +} + @Composable private fun BatteryIcon(level: Int, charging: Boolean, modifier: Modifier = Modifier) { val fillColor = when { diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppTheme.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppTheme.kt index 8c4d505..1333e07 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/AppTheme.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/AppTheme.kt @@ -1,11 +1,13 @@ package com.example.myapplication.ui import android.content.Context +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +@Immutable enum class GlassMode { Liquid, Frosted, 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 70ce390..144113e 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/ImmersiveBackground.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/ImmersiveBackground.kt @@ -5,6 +5,7 @@ import android.util.LruCache import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -21,6 +22,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlin.math.abs +@Immutable data class PosterImmersivePalette( val hero: Color, val pageTop: Color, diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index c66815b..d34cc58 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -44,6 +44,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.runtime.remember @@ -110,6 +111,7 @@ import com.example.myapplication.ui.design.LumaGlassRuntime import com.example.myapplication.ui.design.lumaGlass import com.example.myapplication.ui.design.lumaFallbackGlass +@Immutable internal data class NavTab( val icon: LumaIcon, val labelRes: Int, diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt index 27e2341..f61ebcf 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember @@ -103,6 +104,9 @@ fun rememberLumaLayerBackdrop(): LayerBackdrop? { object LumaColors { val isDark: Boolean get() = AppTheme.isDark + // P1: Cache colors to avoid repeated Color allocation on every access + // These change only when the theme toggles — simple get() is fine since + // Compose recomposition is driven by reading isDark in composition scope. val background: Color get() = if (isDark) Color(0xFF0B0E12) else Color(0xFFF0F3F7) val backgroundElevated: Color @@ -268,6 +272,7 @@ fun Modifier.lumaFallbackGlass( .clip(shape) } +@Immutable enum class LumaGlassLevel { Clear, Regular,