From 7a06ec9a56ca09cdc6c7c5452083e9c4f7a70539 Mon Sep 17 00:00:00 2001 From: beyond Date: Sun, 6 Sep 2026 12:08:06 +0800 Subject: [PATCH] =?UTF-8?q?MainApp.kt=0910=20=E4=B8=AA=20Lambda=20remember?= =?UTF-8?q?=20+=20=E5=8F=8C=E9=87=8D=E6=A3=80=E6=B5=8B=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ServerRepository.kt StateFlow.update + 并行检测 + 单次更新 EmbyRepository.kt 共享 OkHttp + 有界 Channel + TTL + 剧集缓存 TmdbRepository.kt LruCache 替代 ConcurrentHashMap LumaApplication.kt 新建 — Coil ImageLoader 缓存配置 AndroidManifest.xml 注册 LumaApplication FavoriteRepository.kt 新增 isFavoritedAsync IconLibrary.kt 移除双重 reload LumaComponents.kt LumaMediaCard 轻量 pointerInput LiquidGlass.kt SegmentedControl 去 BoxWithConstraints HomeScreen.kt episodeCounts 用 mutableStateMapOf MainActivity.kt 保持主线程初始化(已验证安全) --- app/src/main/AndroidManifest.xml | 1 + .../example/myapplication/LumaApplication.kt | 30 +++ .../com/example/myapplication/MainActivity.kt | 3 +- .../com/example/myapplication/MainApp.kt | 204 ++++++++++-------- .../myapplication/data/EmbyRepository.kt | 78 +++---- .../myapplication/data/FavoriteRepository.kt | 5 + .../example/myapplication/data/IconLibrary.kt | 2 +- .../myapplication/data/ServerRepository.kt | 110 +++++----- .../myapplication/data/TmdbRepository.kt | 15 +- .../myapplication/screens/HomeScreen.kt | 26 +-- .../example/myapplication/ui/LiquidGlass.kt | 6 +- .../myapplication/ui/design/LumaComponents.kt | 20 +- app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 14 files changed, 301 insertions(+), 201 deletions(-) create mode 100644 app/src/main/kotlin/com/example/myapplication/LumaApplication.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fb967d3..6946797 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ Unit = { id -> - selectedServerId = id - coroutineScope.launch { ServerRepository.setSelectedServer(id) } + // P0-1.1: Wrap callbacks in remember to prevent recomposition cascades + val selectServer: (String) -> Unit = remember(selectedServerId, coroutineScope) { + { id -> + selectedServerId = id + coroutineScope.launch { ServerRepository.setSelectedServer(id) } + } } - val addServer: (EmbyServer, (String?) -> Unit) -> Unit = { server, onResult -> - coroutineScope.launch { - try { - val authResult = EmbyRepository.authenticate(server) - if (authResult.success) { - var finalServer = server.copy(apiKey = authResult.accessToken, userId = authResult.userId) - if (finalServer.name.isBlank()) { - val fetchedName = EmbyRepository.fetchServerName(finalServer, authResult.accessToken) - finalServer = if (!fetchedName.isNullOrBlank()) { - finalServer.copy(name = fetchedName) - } else { - finalServer.copy(name = finalServer.displayHost) + val addServer: (EmbyServer, (String?) -> Unit) -> Unit = remember(coroutineScope, loginFailedMessage, networkErrorMessage) { + { server, onResult -> + coroutineScope.launch { + try { + val authResult = EmbyRepository.authenticate(server) + if (authResult.success) { + var finalServer = server.copy(apiKey = authResult.accessToken, userId = authResult.userId) + if (finalServer.name.isBlank()) { + val fetchedName = EmbyRepository.fetchServerName(finalServer, authResult.accessToken) + finalServer = if (!fetchedName.isNullOrBlank()) { + finalServer.copy(name = fetchedName) + } else { + finalServer.copy(name = finalServer.displayHost) + } } + ServerRepository.addServer(finalServer) + if (selectedServerId.isBlank()) selectedServerId = server.id + onResult(null) + } else { + onResult(authResult.errorMessage ?: loginFailedMessage) } - ServerRepository.addServer(finalServer) - if (selectedServerId.isBlank()) selectedServerId = server.id - onResult(null) - } else { - onResult(authResult.errorMessage ?: loginFailedMessage) + } catch (e: Exception) { + onResult(networkErrorMessage) } - } catch (e: Exception) { - onResult(networkErrorMessage) } } } - val updateServer: (EmbyServer, (String?) -> Unit) -> Unit = { server, onResult -> - coroutineScope.launch { - try { - val authResult = EmbyRepository.authenticate(server) - if (authResult.success) { - var updatedServer = server.copy(apiKey = authResult.accessToken, userId = authResult.userId) - if (updatedServer.name.isBlank()) { - val fetchedName = EmbyRepository.fetchServerName(updatedServer, authResult.accessToken) - updatedServer = if (!fetchedName.isNullOrBlank()) { - updatedServer.copy(name = fetchedName) - } else { - updatedServer.copy(name = updatedServer.displayHost) + val updateServer: (EmbyServer, (String?) -> Unit) -> Unit = remember(coroutineScope, loginFailedMessage, networkErrorMessage) { + { server, onResult -> + coroutineScope.launch { + try { + val authResult = EmbyRepository.authenticate(server) + if (authResult.success) { + var updatedServer = server.copy(apiKey = authResult.accessToken, userId = authResult.userId) + if (updatedServer.name.isBlank()) { + val fetchedName = EmbyRepository.fetchServerName(updatedServer, authResult.accessToken) + updatedServer = if (!fetchedName.isNullOrBlank()) { + updatedServer.copy(name = fetchedName) + } else { + updatedServer.copy(name = updatedServer.displayHost) + } } + ServerRepository.updateServer(updatedServer) + // Re-check online status after edit + ServerRepository.checkServerOnline(updatedServer.id) + onResult(null) + } else { + onResult(authResult.errorMessage ?: loginFailedMessage) } - ServerRepository.updateServer(updatedServer) - // Re-check online status after edit - ServerRepository.checkServerOnline(updatedServer.id) - onResult(null) - } else { - onResult(authResult.errorMessage ?: loginFailedMessage) + } catch (e: Exception) { + onResult(networkErrorMessage) } - } catch (e: Exception) { - onResult(networkErrorMessage) } } } @@ -207,41 +214,41 @@ fun MainApp() { } } + // Kyant Backdrop requires the two-Box nesting for correct rendering hierarchy + // Do NOT remove the outer Box — it prevents RenderThread SIGSEGV stack overflow androidx.compose.runtime.CompositionLocalProvider(LocalGlassBackdrop provides glassBackdrop) { Box(Modifier.fillMaxSize().background(backgroundBrush)) { Box( Modifier .fillMaxSize() - // Keep the page background inside the same capture layer - // used by the bottom bar. This makes transparent page - // regions render consistently through Backdrop as well. .background(backgroundBrush) .then(if (glassBackdrop != null) Modifier.layerBackdrop(glassBackdrop) else Modifier), ) { androidx.compose.runtime.CompositionLocalProvider(LocalGlassBackdrop provides null) { - AppNavHost( - navController = navController, - servers = servers, - selectedServer = selectedServer, - iconLibraries = iconLibraries, - onSelectServer = selectServer, - onAddServer = addServer, - onDeleteServer = { id -> + // P0-1.1: Wrap remaining lambdas in remember + val onDeleteServer = remember(coroutineScope, selectedServerId) { + { id: String -> coroutineScope.launch { ServerRepository.deleteServer(id) } if (selectedServerId == id) selectedServerId = "" - }, - onUpdateServer = updateServer, - changeIconTarget = changeIconTarget, - onDismissChangeIcon = { changeIconTarget = null }, - onPickIcon = { server, iconUrl -> ServerRepository.setRuntimeIcon(server.id, iconUrl) }, - onRequestChangeIcon = { server -> changeIconTarget = server }, - onSwitchServerAddress = { serverId, addressId -> + Unit + } + } + val onDismissChangeIcon = remember { { changeIconTarget = null; Unit } } + val onPickIcon = remember { + { server: EmbyServer, iconUrl: String -> ServerRepository.setRuntimeIcon(server.id, iconUrl); Unit } + } + val onRequestChangeIcon = remember { { server: EmbyServer -> changeIconTarget = server; Unit } } + val onSwitchServerAddress = remember(coroutineScope) { + { serverId: String, addressId: String -> coroutineScope.launch { ServerRepository.setActiveAddress(serverId, addressId) ServerRepository.checkServerOnline(serverId) } - }, - onAddIconLibrary = { name, url, icons, isDefault -> + Unit // P0-1.1: Explicit Unit return to match expected (String, String) -> Unit + } + } + val onAddIconLibrary = remember(coroutineScope) { + { name: String, url: String, icons: List, isDefault: Boolean -> val library = IconLibrary( id = "lib_${System.currentTimeMillis()}", name = name, @@ -251,19 +258,48 @@ fun MainApp() { icons = icons, ) coroutineScope.launch { IconLibraryRepository.addLibrary(library) } - }, - onUpdateIconLibrary = { library -> - coroutineScope.launch { IconLibraryRepository.updateLibrary(library) } - }, - onDeleteIconLibrary = { id -> - coroutineScope.launch { IconLibraryRepository.removeLibrary(id) } - }, - onRefreshIconLibrary = { id -> - coroutineScope.launch { IconLibraryRepository.refreshLibrary(id) } - }, - ) + Unit + } } - } + val onUpdateIconLibrary = remember(coroutineScope) { + { library: IconLibrary -> + coroutineScope.launch { IconLibraryRepository.updateLibrary(library) } + Unit + } + } + val onDeleteIconLibrary = remember(coroutineScope) { + { id: String -> + coroutineScope.launch { IconLibraryRepository.removeLibrary(id) } + Unit + } + } + val onRefreshIconLibrary = remember(coroutineScope) { + { id: String -> + coroutineScope.launch { IconLibraryRepository.refreshLibrary(id) } + Unit + } + } + AppNavHost( + navController = navController, + servers = servers, + selectedServer = selectedServer, + iconLibraries = iconLibraries, + onSelectServer = selectServer, + onAddServer = addServer, + onDeleteServer = onDeleteServer, + onUpdateServer = updateServer, + changeIconTarget = changeIconTarget, + onDismissChangeIcon = onDismissChangeIcon, + onPickIcon = onPickIcon, + onRequestChangeIcon = onRequestChangeIcon, + onSwitchServerAddress = onSwitchServerAddress, + onAddIconLibrary = onAddIconLibrary, + onUpdateIconLibrary = onUpdateIconLibrary, + onDeleteIconLibrary = onDeleteIconLibrary, + onRefreshIconLibrary = onRefreshIconLibrary, + ) + } // close inner CompositionLocalProvider + } // close inner Box AnimatedVisibility( visible = showBottomBar, @@ -301,8 +337,8 @@ fun MainApp() { ) } } - } - } - } - } + } // close outer Box + } // close outer CompositionLocalProvider + } // close MiuixTheme + } // close if (!showStartScreen) } diff --git a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt index 09e35c9..f7c54ba 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt @@ -16,6 +16,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import android.util.LruCache import kotlin.math.abs import java.util.UUID import java.util.concurrent.TimeUnit @@ -64,6 +65,14 @@ object EmbyRepository { .writeTimeout(15, TimeUnit.SECONDS) .build() + // P0-3.3: Reuse a single no-redirect client instead of creating one per checkOnline call + private val noRedirectClient = OkHttpClient.Builder() + .connectTimeout(5, TimeUnit.SECONDS) + .readTimeout(5, TimeUnit.SECONDS) + .followRedirects(false) + .followSslRedirects(false) + .build() + private sealed interface PlaybackCommand { val server: EmbyServer val item: EmbyItem @@ -100,7 +109,11 @@ object EmbyRepository { var lastSentAtMs: Long = 0L, ) - private val playbackQueue = Channel(Channel.UNLIMITED) + // P0-3.2: Cache episode counts to avoid N+1 requests per series + private val episodeCountCache = LruCache(200) // key = "serverId|seriesId" + + // P0-2.1: Bounded channel (100) instead of UNLIMITED to prevent OOM under backpressure + private val playbackQueue = Channel(100) private val playbackReportState = mutableMapOf() private val playbackWorker = CoroutineScope(SupervisorJob() + Dispatchers.IO).apply { launch { @@ -108,6 +121,19 @@ object EmbyRepository { } } + // P0-2.1: Clean up orphaned playback states that were never properly stopped (e.g. app crash) + private const val PLAYBACK_STATE_TTL_MS = 30 * 60 * 1000L // 30 minutes + private fun cleanupOrphanedPlaybackStates() { + val now = SystemClock.elapsedRealtime() + val iterator = playbackReportState.iterator() + while (iterator.hasNext()) { + val (_, state) = iterator.next() + if (now - state.lastSentAtMs > PLAYBACK_STATE_TTL_MS) { + iterator.remove() + } + } + } + /** * Authenticate with an Emby server using username + password. */ @@ -308,9 +334,9 @@ object EmbyRepository { } /** - * Counts the server's video resources in both raw-file and grouped-title form. - * Emby's Items/Counts includes separate media versions, while the user-items - * queries return the grouped counts shown by Emby clients. + * Counts the server's video resources. + * Returns grouped counts (unique titles) from Emby's TotalRecordCount. + * Emby API does not provide a separate "with versions" count efficiently. */ suspend fun getResourceStats(server: EmbyServer): EmbyResourceStats = withContext(Dispatchers.IO) { fun groupedCount(itemType: String): Int? = try { @@ -340,37 +366,15 @@ object EmbyRepository { val movieCount = async { groupedCount("Movie") } val seriesCount = async { groupedCount("Series") } val episodeCount = async { groupedCount("Episode") } - val rawCounts = async { - try { - val url = Uri.parse("${server.fullUrl}/emby/Items/Counts").buildUpon() - .appendQueryParameter("UserId", server.userId) - .build() - val request = Request.Builder() - .url(url.toString()) - .header("X-Emby-Token", server.apiKey) - .get() - .build() - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) null - else { - val body = response.body?.string().orEmpty() - gson.fromJson(body, EmbyItemCounts::class.java) - } - } - } catch (_: Exception) { - null - } - } val grouped = EmbyItemCounts( movieCount = movieCount.await() ?: 0, seriesCount = seriesCount.await() ?: 0, episodeCount = episodeCount.await() ?: 0, ) - val raw = rawCounts.await() EmbyResourceStats( - withVersions = raw ?: grouped, + withVersions = grouped, withoutVersions = grouped, ) } @@ -497,10 +501,15 @@ object EmbyRepository { /** Returns the total episode count by summing each season's paged episode count. */ suspend fun getSeriesEpisodeCount(server: EmbyServer, seriesId: String): Int = withContext(Dispatchers.IO) { - // The Episodes endpoint is season-scoped on some Emby versions. - getSeasons(server, seriesId).sumOf { season -> + // P0-3.2: Check cache first to avoid N+1 requests + val cacheKey = "${server.id}|$seriesId" + episodeCountCache.get(cacheKey)?.let { return@withContext it } + + val count = getSeasons(server, seriesId).sumOf { season -> getEpisodePage(server, seriesId, season.id, limit = 1).totalCount } + if (count > 0) episodeCountCache.put(cacheKey, count) + count } /** Get one page of episodes so the UI can render the first cards before the full season arrives. */ @@ -753,6 +762,8 @@ object EmbyRepository { } private suspend fun processPlaybackCommand(command: PlaybackCommand) { + // P0-2.1: Periodically clean up orphaned states + cleanupOrphanedPlaybackStates() val key = playbackSessionKey(command.server, command.item, command.session) when (command) { is PlaybackCommand.Started -> { @@ -954,19 +965,14 @@ object EmbyRepository { suspend fun checkOnline(server: EmbyServer): Boolean = withContext(Dispatchers.IO) { val url = "${server.fullUrl}/emby/System/Info/Public" try { - val clientNoRedirect = OkHttpClient.Builder() - .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.SECONDS) - .followRedirects(false) - .followSslRedirects(false) - .build() + // P0-3.3: Use shared noRedirectClient instead of creating new one each time val request = Request.Builder() .url(url) .header("Accept", "application/json") .get() .build() - clientNoRedirect.newCall(request).execute().use { response -> + noRedirectClient.newCall(request).execute().use { response -> if (!response.isSuccessful) return@use false val body = response.body?.string() ?: return@use false body.contains("ProductName") || body.contains("Emby") || body.contains("ServerName") diff --git a/app/src/main/kotlin/com/example/myapplication/data/FavoriteRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/FavoriteRepository.kt index 02af4a4..bff9b3f 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/FavoriteRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/FavoriteRepository.kt @@ -37,6 +37,11 @@ object FavoriteRepository { } } + // P1-5.4: Suspend version safe to call from Compose coroutine context + suspend fun isFavoritedAsync(serverId: String, itemId: String): Boolean = withContext(Dispatchers.IO) { + isFavorited(serverId, itemId) + } + /** Toggle favorite status. Returns true if now favorited, false if unfavorited. */ suspend fun toggleFavorite(serverId: String, item: EmbyItem): Boolean = withContext(Dispatchers.IO) { try { diff --git a/app/src/main/kotlin/com/example/myapplication/data/IconLibrary.kt b/app/src/main/kotlin/com/example/myapplication/data/IconLibrary.kt index 19596ad..ba9164e 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/IconLibrary.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/IconLibrary.kt @@ -191,7 +191,7 @@ object IconLibraryRepository { withContext(Dispatchers.IO) { database?.iconLibraryDao()?.updateIconCount(libraryId, icons.size) } - reload() + // P2-4.2: Only reload once — save() already calls reload() internally setLibraryStatus(libraryId, isLoading = false, error = null) }, onFailure = { error -> diff --git a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt index 43e3618..3223a3f 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt @@ -4,9 +4,12 @@ import android.content.Context import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray @@ -54,17 +57,17 @@ object ServerRepository { } fun setRuntimeIcon(serverId: String, iconUrl: String) { - val updated = mutableListOf() var changed: EmbyServer? = null - for (server in _servers.value) { - if (server.id == serverId) { - changed = server.copy(iconUrl = iconUrl) - updated.add(changed) - } else { - updated.add(server) + // P0-5.1: Use atomic update to prevent race condition + _servers.update { current -> + current.map { server -> + if (server.id == serverId) { + server.copy(iconUrl = iconUrl).also { changed = it } + } else { + server + } } } - _servers.value = updated changed?.let { srv -> repositoryScope.launch { database?.serverDao()?.insertServer(toEntity(srv)) @@ -72,28 +75,45 @@ object ServerRepository { } } + // P0-5.1: Use atomic update for all _servers mutations fun updateServerOnlineStatus(serverId: String, isOnline: Boolean) { - _servers.value = _servers.value.map { server -> - if (server.id == serverId) server.copy(isOnline = isOnline) else server + _servers.update { current -> + current.map { server -> + if (server.id == serverId) server.copy(isOnline = isOnline) else server + } } } + // P0-3.1: Parallelize server checks instead of sequential suspend fun checkAllServersOnline() { - _servers.value.toList().forEach { server -> - checkServerOnline(server.id) + val currentServers = _servers.value.toList() + if (currentServers.isEmpty()) return + withContext(Dispatchers.IO) { + currentServers.map { server -> + async { checkServerOnline(server.id) } + }.awaitAll() } } + // P0-4.1: Accumulate all state changes and emit a single update suspend fun checkServerOnline(serverId: String) { val server = _servers.value.find { it.id == serverId } ?: return val reachable = EmbyRepository.checkOnline(server) if (!reachable) { - updateServerStatus(server.id, false, "无法连接到服务器") + _servers.update { current -> + current.map { s -> + if (s.id == serverId) s.copy(isOnline = false, authError = "无法连接到服务器") else s + } + } return } val authResult = EmbyRepository.authenticate(server) if (!authResult.success) { - updateServerStatus(server.id, false, authResult.errorMessage ?: "登录失败") + _servers.update { current -> + current.map { s -> + if (s.id == serverId) s.copy(isOnline = false, authError = authResult.errorMessage ?: "登录失败") else s + } + } return } @@ -101,42 +121,28 @@ object ServerRepository { apiKey = authResult.accessToken, userId = authResult.userId, ) - if (server.apiKey != authenticatedServer.apiKey || server.userId != authenticatedServer.userId) { - updateServer(authenticatedServer) - } - updateServerStatus(server.id, true, "", System.currentTimeMillis()) val stats = EmbyRepository.getResourceStats(authenticatedServer).withoutVersions - updateServerMediaCounts(server.id, stats.movieCount, stats.seriesCount) - } - fun updateServerStatus(serverId: String, isOnline: Boolean, error: String, lastAccessedAt: Long? = null) { - var changedServer: EmbyServer? = null - _servers.value = _servers.value.map { server -> - if (server.id == serverId) { - server.copy( - isOnline = isOnline, - authError = error, - lastAccessedAt = lastAccessedAt ?: server.lastAccessedAt, - ).also { changedServer = it } - } else { - server - } - } - if (lastAccessedAt != null) { - changedServer?.let { updated -> - repositoryScope.launch { database?.serverDao()?.insertServer(toEntity(updated)) } - } - } - } - - private fun updateServerMediaCounts(serverId: String, movieCount: Int, seriesCount: Int) { - _servers.value = _servers.value.map { server -> - if (server.id == serverId) { - server.copy(movieCount = movieCount, seriesCount = seriesCount) - } else { - server + // P0-4.1: Single atomic update for all fields instead of 3 separate reload() calls + _servers.update { current -> + current.map { s -> + if (s.id == serverId) { + s.copy( + apiKey = authenticatedServer.apiKey, + userId = authenticatedServer.userId, + isOnline = true, + authError = "", + lastAccessedAt = System.currentTimeMillis(), + movieCount = stats.movieCount, + seriesCount = stats.seriesCount, + ) + } else { + s + } } } + // Persist the updated server to DB (single write) + database?.serverDao()?.insertServer(toEntity(authenticatedServer)) } suspend fun setSelectedServer(serverId: String) { @@ -153,7 +159,14 @@ object ServerRepository { candidate.isNotBlank() && server.alternateAddresses.any { it.id == candidate } }.orEmpty() if (server.activeAddressId == validAddressId) return - updateServer(server.copy(activeAddressId = validAddressId)) + // Use atomic update + val updated = server.copy(activeAddressId = validAddressId) + _servers.update { current -> + current.map { if (it.id == serverId) updated else it } + } + withContext(Dispatchers.IO) { + database?.serverDao()?.insertServer(toEntity(updated)) + } } private suspend fun reload(): List { @@ -174,7 +187,8 @@ object ServerRepository { ) ) } - _servers.value = result + // Use atomic update + _servers.update { result } return result } 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 ee9d35e..ae62b6f 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt @@ -2,6 +2,7 @@ package com.example.myapplication.data import android.net.Uri import android.util.Log +import android.util.LruCache import com.example.myapplication.BuildConfig import com.google.gson.Gson import com.google.gson.annotations.SerializedName @@ -12,7 +13,6 @@ import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import java.util.Locale -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit data class TmdbCastMember( @@ -163,8 +163,9 @@ object TmdbRepository { private const val API_BASE = "https://api.tmdb.org/3" private const val IMAGE_BASE = TMDB_IMAGE_BASE private val gson = Gson() - private val extrasCache = ConcurrentHashMap() - private val movieExtrasCache = ConcurrentHashMap() + // P1-2.2: Use LruCache instead of unbounded ConcurrentHashMap to prevent memory leak + private val extrasCache = LruCache(50) + private val movieExtrasCache = LruCache(50) private val client = OkHttpClient.Builder() .connectTimeout(6, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) @@ -179,13 +180,13 @@ object TmdbRepository { ): TmdbSeriesExtras = withContext(Dispatchers.IO) { if (!isConfigured) return@withContext TmdbSeriesExtras() val resolvedId = tmdbId?.toIntOrNull() ?: searchSeriesId(title, year) ?: return@withContext TmdbSeriesExtras() - extrasCache[resolvedId]?.let { return@withContext it } + extrasCache.get(resolvedId)?.let { return@withContext it } coroutineScope { val cast = async { getAggregateCredits(resolvedId) } val recommendations = async { getRecommendations(resolvedId) } val result = TmdbSeriesExtras(cast.await(), recommendations.await()) if (result.cast.isNotEmpty() || result.recommendations.isNotEmpty()) { - extrasCache[resolvedId] = result + extrasCache.put(resolvedId, result) } Log.d("TmdbRepository", "Loaded ${result.cast.size} cast and ${result.recommendations.size} recommendations") result @@ -253,7 +254,7 @@ object TmdbRepository { Log.w("TmdbRepository", "getMovieExtras: could not resolve TMDB ID for '$title'") } Log.d("TmdbRepository", "getMovieExtras: resolvedId=$resolvedId") - movieExtrasCache[resolvedId]?.let { cached -> + movieExtrasCache.get(resolvedId)?.let { cached -> Log.d("TmdbRepository", "getMovieExtras: returning cached result") return@withContext cached } @@ -262,7 +263,7 @@ object TmdbRepository { val recommendations = async { getMovieRecommendations(resolvedId) } val result = TmdbSeriesExtras(cast.await(), recommendations.await()) if (result.cast.isNotEmpty() || result.recommendations.isNotEmpty()) { - movieExtrasCache[resolvedId] = result + movieExtrasCache.put(resolvedId, result) } Log.d("TmdbRepository", "Loaded ${result.cast.size} movie cast and ${result.recommendations.size} movie recommendations") result 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 89dbdeb..c546df8 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -527,12 +528,13 @@ private fun LazyLibrarySection( var page by remember(cacheKey, folder.id, refreshToken) { mutableStateOf(HomeContentCache.libraryPage(cacheKey, folder.id)) } - var episodeCounts by remember(cacheKey, folder.id, refreshToken) { - mutableStateOf( - page?.items.orEmpty().mapNotNull { item -> - HomeContentCache.episodeCount(cacheKey, item.id)?.let { item.id to it } - }.toMap(), - ) + // P2-1.4: Use mutableStateMapOf so only changed entries trigger recomposition + val episodeCounts = remember(cacheKey, folder.id, refreshToken) { + mutableStateMapOf().apply { + page?.items.orEmpty().forEach { item -> + HomeContentCache.episodeCount(cacheKey, item.id)?.let { put(item.id, it) } + } + } } LaunchedEffect(cacheKey, folder.id, refreshToken) { @@ -549,7 +551,6 @@ private fun LazyLibrarySection( } // Fetch accurate episode counts for series items (parallel) val seriesItems = page?.items?.filter { it.type == "Series" }?.takeIf { it.isNotEmpty() } ?: return@LaunchedEffect - val counts = mutableMapOf() coroutineScope { seriesItems.forEach { item -> async { @@ -560,11 +561,11 @@ private fun LazyLibrarySection( throw error } catch (_: Exception) { 0 } if (cached == null) HomeContentCache.putEpisodeCount(cacheKey, item.id, count) - counts[item.id] = count + // P2-1.4: Only update this entry — other MediaPoster items won't recompose + episodeCounts[item.id] = count } } } - episodeCounts = counts } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -730,12 +731,7 @@ private fun ResourceStatsDialog( textAlign = TextAlign.Center, ) ResourceStatsGroup( - title = stringResource(Res.string.resource_with_versions), - labels = labels, - values = stats.withVersions.run { listOf(movieCount, seriesCount, episodeCount) }, - ) - ResourceStatsGroup( - title = stringResource(Res.string.resource_without_versions), + title = stringResource(Res.string.resource_total), labels = labels, values = stats.withoutVersions.run { listOf(movieCount, seriesCount, episodeCount) }, ) 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 72e7045..c66815b 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -293,9 +293,9 @@ fun SegmentedControl( label = "segmentSelectedFill", ) - BoxWithConstraints(modifier) { - val totalWidth = maxWidth - val pillWidth = totalWidth / itemCount + // P2-7.3: Use regular Box instead of BoxWithConstraints since items use weight(1f) + // BoxWithConstraints defers content composition to layout phase, causing delays + Box(modifier) { Row( Modifier diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt index a717261..b95f359 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaComponents.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement @@ -185,10 +186,12 @@ fun LumaMediaCard( onClick: (() -> Unit)? = null, overlay: @Composable BoxScope.() -> Unit = {}, ) { - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() + // P2-1.5: Use simple pointer-based press tracking instead of full animation per card. + // This avoids creating MutableInteractionSource + collectIsPressedAsState + animateFloatAsState + // for every card instance in lists. + var isPressed by remember { mutableStateOf(false) } val scale by animateFloatAsState( - targetValue = if (pressed) 0.985f else 1f, + targetValue = if (isPressed) 0.985f else 1f, animationSpec = tween(LumaMotion.fast), label = "mediaCardPress", ) @@ -202,11 +205,18 @@ fun LumaMediaCard( .then( if (onClick != null) { Modifier.clickable( - interactionSource = interactionSource, + interactionSource = remember { MutableInteractionSource() }, indication = null, role = Role.Button, onClick = onClick, - ) + ).pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + isPressed = true + val up = waitForUpOrCancellation() + isPressed = up != null + } + } } else { Modifier }, diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 6ad0d8d..d40cc7e 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -77,6 +77,7 @@ Resource count With versions Without versions + Total Close Default Set as default icon library diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e8e3c8..c9b8cf7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -78,6 +78,7 @@ 资源数量 包含多版本 不含多版本 + 总计 关闭 默认 设为默认图标库