MainApp.kt 10 个 Lambda remember + 双重检测合并

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	保持主线程初始化(已验证安全)
This commit is contained in:
beyond committed 2026-09-06 12:08:06 +08:00
1 parent 0a260a47c6
commit 7a06ec9a56
14 files changed
+301 -201

No files matched your search

+1
View File
@@ -6,6 +6,7 @@
<uses-sdk tools:overrideLibrary="top.yukonga.miuix.kmp.blur" />
<application
android:name=".LumaApplication"
android:usesCleartextTraffic="true"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -0,0 +1,30 @@
package com.example.myapplication
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
/**
* P1-6.1: Custom Application class with tuned Coil ImageLoader caches.
* Registers as ImageLoaderFactory so all AsyncImage composables use the optimized config.
*/
class LumaApplication : Application(), ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.15) // 15% of available RAM
.build()
}
.diskCache {
DiskCache.Builder()
.directory(cacheDir.resolve("image_cache"))
.maxSizePercent(0.02) // 2% of disk
.build()
}
.crossfade(true)
.build()
}
}
@@ -21,7 +21,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Initialize on main thread (these are fast SharedPreferences/DB reads)
// These are fast SharedPreferences/DB singleton inits — safe on main thread
AppLanguage.init(this)
AppTheme.init(this)
IconLibraryRepository.init(this)
@@ -34,5 +34,4 @@ class MainActivity : ComponentActivity() {
MainApp()
}
}
}
@@ -70,13 +70,13 @@ fun MainApp() {
val coroutineScope = rememberCoroutineScope()
val selectedServer = servers.find { it.id == selectedServerId }
// P0-8.3: Single check instead of two duplicate LaunchedEffects
var serversChecked by remember { mutableStateOf(false) }
LaunchedEffect(servers.size) {
if (servers.isNotEmpty()) ServerRepository.checkAllServersOnline()
}
// Also refresh on first composition (app restart) when servers are already loaded
LaunchedEffect(Unit) {
delay(500) // wait for database load
if (servers.isNotEmpty()) ServerRepository.checkAllServersOnline()
if (servers.isNotEmpty() && !serversChecked) {
serversChecked = true
ServerRepository.checkAllServersOnline()
}
}
LaunchedEffect(servers) {
if (servers.isNotEmpty()) {
@@ -142,58 +142,65 @@ fun MainApp() {
),
)
val selectServer: (String) -> 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<com.example.myapplication.data.IconItem>, 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)
}
@@ -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<PlaybackCommand>(Channel.UNLIMITED)
// P0-3.2: Cache episode counts to avoid N+1 requests per series
private val episodeCountCache = LruCache<String, Int>(200) // key = "serverId|seriesId"
// P0-2.1: Bounded channel (100) instead of UNLIMITED to prevent OOM under backpressure
private val playbackQueue = Channel<PlaybackCommand>(100)
private val playbackReportState = mutableMapOf<String, PlaybackReportState>()
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")
@@ -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 {
@@ -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 ->
@@ -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<EmbyServer>()
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<EmbyServer> {
@@ -174,7 +187,8 @@ object ServerRepository {
)
)
}
_servers.value = result
// Use atomic update
_servers.update { result }
return result
}
@@ -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<Int, TmdbSeriesExtras>()
private val movieExtrasCache = ConcurrentHashMap<Int, TmdbSeriesExtras>()
// P1-2.2: Use LruCache instead of unbounded ConcurrentHashMap to prevent memory leak
private val extrasCache = LruCache<Int, TmdbSeriesExtras>(50)
private val movieExtrasCache = LruCache<Int, TmdbSeriesExtras>(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
@@ -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<String, Int>().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<String, Int>()
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) },
)
@@ -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
@@ -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
},
+1
View File
@@ -77,6 +77,7 @@
<string name="resource_stats_title">Resource count</string>
<string name="resource_with_versions">With versions</string>
<string name="resource_without_versions">Without versions</string>
<string name="resource_total">Total</string>
<string name="resource_close">Close</string>
<string name="icon_default_badge">Default</string>
<string name="set_default_library">Set as default icon library</string>
+1
View File
@@ -78,6 +78,7 @@
<string name="resource_stats_title">资源数量</string>
<string name="resource_with_versions">包含多版本</string>
<string name="resource_without_versions">不含多版本</string>
<string name="resource_total">总计</string>
<string name="resource_close">关闭</string>
<string name="icon_default_badge">默认</string>
<string name="set_default_library">设为默认图标库</string>