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:
1 parent
0a260a47c6
commit
7a06ec9a56
14 files changed
+301
-201
No files matched your search
@@ -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
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user