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,