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 @@
资源数量
包含多版本
不含多版本
+ 总计
关闭
默认
设为默认图标库