fix: 第一轮性能优化 — R8启用、协程泄漏修复、@Immutable注解、状态提取、并行化

- 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
This commit is contained in:
beyond committed 2026-09-06 13:30:56 +08:00
1 parent b49fbdca46
commit 3670466a65
16 files changed
+309 -151

No files matched your search

+6 -1
View File
@@ -30,7 +30,12 @@ android {
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
+63
View File
@@ -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 <fields>;
}
-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.** { *; }
@@ -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,
@@ -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 = "",
@@ -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<WatchlistEntity>): List<TmdbEpisodeCalendar> = withContext(Dispatchers.IO) {
if (!isConfigured || watchlist.isEmpty()) return@withContext emptyList()
val results = mutableListOf<TmdbEpisodeCalendar>()
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<TmdbEpisodeCalendar> {
val results = mutableListOf<TmdbEpisodeCalendar>()
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 <T> get(url: String, type: Class<T>): T? {
@@ -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
}
}
}
}
@@ -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,
@@ -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<EmbyServer?>(null) }
var addressPickerTarget by remember { mutableStateOf<EmbyServer?>(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
@@ -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<DetailCastMember>, 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<TmdbRecommendation>, 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]) }
}
}
@@ -142,8 +142,9 @@ fun RecordsScreen(
) {
var allFavorites by remember { mutableStateOf<List<FavoriteEntity>>(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()
}
@@ -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()) {
@@ -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 {
@@ -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,
@@ -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,
@@ -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,
@@ -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,