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:
1 parent
b49fbdca46
commit
3670466a65
16 files changed
+309
-151
No files matched your search
@@ -30,7 +30,12 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+63
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user