fix: 第二轮性能优化 — 轮询合并、HTTP缓存、并行化、remember优化
- VideoPlayerScreen: 合并4个轮询循环为1个(wifi+battery+format+speed)
- VideoPlayerScreen: 移除seek bar重复progress写入
- EmbyRepository: 添加HTTP磁盘缓存(20MB) + retry interceptor
- EmbyRepository: searchItems .map{await()} → .awaitAll() 真并发
- EmbyRepository: getSeriesEpisodeCount season请求并行化
- TmdbRepository: 添加Semaphore(8)限制并发season请求
- TmdbRepository: 只获取最近5个season减少请求数
- ServerRepository: addServer/updateServer改为乐观更新
- ServerRepository: init()添加双重检查锁定线程安全
- MediaDetailScreen: 移除调试Log.d调用
- AppNavHost: onViewAll lambda添加remember
- SearchResultsCache: 单槽→LruCache(5)支持多搜索词缓存
- RecordsScreen: LaunchedEffect添加watchlistVersion key支持刷新
- RecordsScreen: filterCalendarEpisodes/groupBy添加remember
- RecordsScreen: 预计算watchByTmdbId Map替代O(n) find
- LumaDesignSystem: 添加isLowEndDevice检测禁用lens效果
- LiquidGlass: 低端设备跳过lens GPU pass
- HomeScreen/LibraryScreen/RecordsScreen: ImageRequest.Builder remember
This commit is contained in:
1 parent
3670466a65
commit
0ca7cf39b7
13 files changed
+175
-88
No files matched your search
@@ -1,16 +1,28 @@
|
||||
package com.example.myapplication
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.example.myapplication.data.EmbyRepository
|
||||
import com.example.myapplication.ui.design.LumaGlassRuntime
|
||||
|
||||
/**
|
||||
* 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 onCreate() {
|
||||
super.onCreate()
|
||||
// P1: Initialize EmbyRepository with app context for HTTP disk cache
|
||||
EmbyRepository.init(this)
|
||||
// P1: Detect low-end devices to disable expensive lens effects
|
||||
val am = getSystemService(ActivityManager::class.java)
|
||||
LumaGlassRuntime.isLowEndDevice = am?.isLowRamDevice == true || Runtime.getRuntime().availableProcessors() <= 4
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.memoryCache {
|
||||
|
||||
@@ -9,15 +9,21 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Cache
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Interceptor
|
||||
import android.util.LruCache
|
||||
import kotlin.math.abs
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -59,11 +65,38 @@ object EmbyRepository {
|
||||
private val deviceId: String = UUID.randomUUID().toString()
|
||||
|
||||
private val gson = Gson()
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// P1: Retry interceptor for transient network failures on GET requests
|
||||
private val retryInterceptor = Interceptor { chain ->
|
||||
var lastException: Exception? = null
|
||||
repeat(2) { attempt ->
|
||||
try {
|
||||
return@Interceptor chain.proceed(chain.request())
|
||||
} catch (e: Exception) {
|
||||
lastException = e
|
||||
if (attempt < 1) Thread.sleep(300L * (attempt + 1))
|
||||
}
|
||||
}
|
||||
throw lastException ?: Exception("Retry failed")
|
||||
}
|
||||
|
||||
// P1: HTTP disk cache + retry — initialized lazily from Application context
|
||||
@Volatile private var _client: OkHttpClient? = null
|
||||
fun init(context: android.content.Context) {
|
||||
if (_client != null) return
|
||||
synchronized(this) {
|
||||
if (_client != null) return
|
||||
_client = OkHttpClient.Builder()
|
||||
.cache(Cache(context.cacheDir.resolve("emby_http"), 20L * 1024 * 1024))
|
||||
.addInterceptor(retryInterceptor)
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
private val client: OkHttpClient
|
||||
get() = _client ?: throw IllegalStateException("EmbyRepository.init(context) must be called first")
|
||||
|
||||
// P0-3.3: Reuse a single no-redirect client instead of creating one per checkOnline call
|
||||
private val noRedirectClient = OkHttpClient.Builder()
|
||||
@@ -367,7 +400,7 @@ object EmbyRepository {
|
||||
} else {
|
||||
val episodeCounts = seriesItems
|
||||
.map { item -> async { item.id to getSeriesEpisodeCount(server, item.id) } }
|
||||
.map { it.await() }
|
||||
.awaitAll() // P0: awaitAll() for true parallelism instead of sequential await
|
||||
.toMap()
|
||||
items.map { item ->
|
||||
episodeCounts[item.id]?.let { count ->
|
||||
@@ -462,8 +495,12 @@ object EmbyRepository {
|
||||
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
|
||||
// P1: Parallelize season episode-count requests
|
||||
val seasons = getSeasons(server, seriesId)
|
||||
val count = coroutineScope {
|
||||
seasons.map { season ->
|
||||
async { getEpisodePage(server, seriesId, season.id, limit = 1).totalCount }
|
||||
}.awaitAll().sum()
|
||||
}
|
||||
if (count > 0) episodeCountCache.put(cacheKey, count)
|
||||
count
|
||||
|
||||
@@ -21,32 +21,37 @@ object ServerRepository {
|
||||
val servers: StateFlow<List<EmbyServer>> = _servers.asStateFlow()
|
||||
|
||||
private var database: AppDatabase? = null
|
||||
private var initialized = false
|
||||
@Volatile private var initialized = false
|
||||
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
// P2: Thread-safe initialization with double-checked locking
|
||||
fun init(context: Context) {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
database = AppDatabase.getInstance(context)
|
||||
repositoryScope.launch {
|
||||
migrateLegacyPreferences(context)
|
||||
removeLegacySampleServers()
|
||||
reload()
|
||||
synchronized(this) {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
database = AppDatabase.getInstance(context)
|
||||
repositoryScope.launch {
|
||||
migrateLegacyPreferences(context)
|
||||
removeLegacySampleServers()
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// P1: Optimistic update — directly update state instead of full DB reload
|
||||
suspend fun addServer(server: EmbyServer) {
|
||||
withContext(Dispatchers.IO) {
|
||||
database?.serverDao()?.insertServer(toEntity(server))
|
||||
}
|
||||
reload()
|
||||
_servers.update { current -> current + server }
|
||||
}
|
||||
|
||||
suspend fun updateServer(server: EmbyServer) {
|
||||
withContext(Dispatchers.IO) {
|
||||
database?.serverDao()?.insertServer(toEntity(server))
|
||||
}
|
||||
reload()
|
||||
_servers.update { current -> current.map { if (it.id == server.id) server else it } }
|
||||
}
|
||||
|
||||
suspend fun deleteServer(serverId: String) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.Locale
|
||||
@@ -320,6 +322,9 @@ object TmdbRepository {
|
||||
return result
|
||||
}
|
||||
|
||||
// P1: Semaphore to limit concurrent season requests (TMDB rate limiting)
|
||||
private val seasonSemaphore = Semaphore(8)
|
||||
|
||||
/** Get calendar episodes for all watchlist items. */
|
||||
suspend fun getCalendarEpisodes(watchlist: List<WatchlistEntity>): List<TmdbEpisodeCalendar> = withContext(Dispatchers.IO) {
|
||||
if (!isConfigured || watchlist.isEmpty()) return@withContext emptyList()
|
||||
@@ -367,22 +372,27 @@ object TmdbRepository {
|
||||
?: seriesDetail.logoPath
|
||||
val logoUrl = logoPath?.let { IMAGE_BASE.replace("w342", "w300") + it }
|
||||
|
||||
// P1: Parallelize season requests within each series
|
||||
// P1: Parallelize season requests with semaphore for rate limiting
|
||||
// P1: Only fetch recent seasons (last 5 with air dates) to reduce request count
|
||||
val recentSeasons = seriesDetail.seasons
|
||||
.filter { it.seasonNumber > 0 && it.episodeCount > 0 }
|
||||
.sortedByDescending { it.seasonNumber }
|
||||
.take(5)
|
||||
.sortedBy { it.seasonNumber }
|
||||
val seasonEpisodes = coroutineScope {
|
||||
seriesDetail.seasons
|
||||
.filter { it.seasonNumber > 0 && it.episodeCount > 0 }
|
||||
.map { season ->
|
||||
async {
|
||||
recentSeasons.map { season ->
|
||||
async {
|
||||
seasonSemaphore.withPermit {
|
||||
get(
|
||||
"$API_BASE/tv/$tmdbId/season/${season.seasonNumber}?language=${language()}",
|
||||
TmdbSeasonDetailResponse::class.java,
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
seriesDetail.seasons
|
||||
.filter { it.seasonNumber > 0 && it.episodeCount > 0 }
|
||||
recentSeasons
|
||||
.zip(seasonEpisodes)
|
||||
.forEach { (season, seasonDetail) ->
|
||||
val episodes = seasonDetail?.episodes.orEmpty()
|
||||
|
||||
@@ -119,16 +119,20 @@ fun AppNavHost(
|
||||
popExitTransition = { appPopExitTransition() },
|
||||
) {
|
||||
composable(AppRoute.Home.pattern) {
|
||||
// P1: Remember lambdas passed to screen composables
|
||||
val homeViewAll = remember(navController) {
|
||||
{ serverId: String, libraryId: String, title: String ->
|
||||
navController.navigate(AppRoute.HomeLibraryAll.create(serverId, libraryId, title)) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
HomeScreen(
|
||||
servers = servers,
|
||||
selected = selectedServer,
|
||||
onSelectServer = onSelectServer,
|
||||
onOpenDetail = { openDetail(selectedServer?.id.orEmpty(), it) },
|
||||
onViewAll = { serverId, libraryId, title ->
|
||||
navController.navigate(AppRoute.HomeLibraryAll.create(serverId, libraryId, title)) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
onViewAll = homeViewAll,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -283,6 +283,11 @@ private fun FeaturedItemBanner(
|
||||
) {
|
||||
val imageUrl = heroImageUrl(item, serverUrl)
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
// P2: Remember ImageRequest to avoid allocation on every recomposition
|
||||
val context = LocalContext.current
|
||||
val imageRequest = remember(imageUrl) {
|
||||
imageUrl?.let { ImageRequest.Builder(context).data(it).crossfade(true).build() }
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
@@ -295,9 +300,9 @@ private fun FeaturedItemBanner(
|
||||
)
|
||||
.background(immersive.pageTop),
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
if (imageRequest != null) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).crossfade(true).build(),
|
||||
model = imageRequest,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = ColorPainter(immersive.pageTop),
|
||||
@@ -642,6 +647,10 @@ private fun MyMediaGrid(libraries: List<EmbyMediaFolder>, serverUrl: String, onS
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val folderImageUrl = "$serverUrl/emby/Items/${folder.id}/Images/Primary?maxWidth=300&quality=80"
|
||||
val context = LocalContext.current
|
||||
val folderImageRequest = remember(folderImageUrl) {
|
||||
ImageRequest.Builder(context).data(folderImageUrl).crossfade(true).build()
|
||||
}
|
||||
Box(
|
||||
Modifier.width(176.dp).height(100.dp)
|
||||
.clip(RoundedCornerShape(LumaShapes.medium))
|
||||
@@ -649,7 +658,7 @@ private fun MyMediaGrid(libraries: List<EmbyMediaFolder>, serverUrl: String, onS
|
||||
.clickable { onSelect(folder) },
|
||||
) {
|
||||
LumaAsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current).data(folderImageUrl).crossfade(true).build(),
|
||||
model = folderImageRequest,
|
||||
contentDescription = folder.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
||||
@@ -191,7 +191,11 @@ private fun ServerCard(server: EmbyServer, tint: Color, onClick: () -> Unit, onC
|
||||
Column(modifier = Modifier.fillMaxWidth().background(pressBg).padding(horizontal = 12.dp, vertical = 12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (server.iconUrl.isNotBlank()) {
|
||||
LumaAsyncImage(model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(), contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)), contentScale = ContentScale.Crop)
|
||||
val ctx = LocalContext.current
|
||||
val iconRequest = remember(server.iconUrl) {
|
||||
ImageRequest.Builder(ctx).data(server.iconUrl).crossfade(true).build()
|
||||
}
|
||||
LumaAsyncImage(model = iconRequest, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)), contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(Brush.linearGradient(listOf(tint, tint.copy(alpha = 0.7f)))), contentAlignment = Alignment.Center) {
|
||||
LumaIconView(LumaIcon.Server, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||
@@ -256,8 +260,12 @@ private fun ServerGridCard(server: EmbyServer, tint: Color, onClick: () -> Unit,
|
||||
Column(modifier = Modifier.fillMaxWidth().background(pressBg).padding(10.dp), horizontalAlignment = Alignment.Start) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (server.iconUrl.isNotBlank()) {
|
||||
val ctx = LocalContext.current
|
||||
val iconRequest = remember(server.iconUrl) {
|
||||
ImageRequest.Builder(ctx).data(server.iconUrl).crossfade(true).build()
|
||||
}
|
||||
LumaAsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current).data(server.iconUrl).crossfade(true).build(),
|
||||
model = iconRequest,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)),
|
||||
contentScale = ContentScale.Crop,
|
||||
|
||||
@@ -372,7 +372,6 @@ fun MediaDetailScreen(
|
||||
loadingTmdb = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
android.util.Log.d("MediaDetail", "TMDB loading: type=${detail.type}, tmdbId=$tmdbId, name=${detail.name}, year=${detail.productionYear}")
|
||||
loadingTmdb = true
|
||||
tmdbExtras = try {
|
||||
if (detail.type == "Series") {
|
||||
@@ -387,7 +386,6 @@ fun MediaDetailScreen(
|
||||
} finally {
|
||||
loadingTmdb = false
|
||||
}
|
||||
android.util.Log.d("MediaDetail", "TMDB result: cast=${tmdbExtras.cast.size}, recs=${tmdbExtras.recommendations.size}")
|
||||
}
|
||||
|
||||
val castMembers = remember(detail.people, tmdbExtras.cast, server?.fullUrl) {
|
||||
|
||||
@@ -254,11 +254,15 @@ private fun ServerHeader(server: EmbyServer) {
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (server.iconUrl.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
val ctx = LocalContext.current
|
||||
val iconRequest = remember(server.iconUrl) {
|
||||
ImageRequest.Builder(ctx)
|
||||
.data(server.iconUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
.build()
|
||||
}
|
||||
AsyncImage(
|
||||
model = iconRequest,
|
||||
contentDescription = server.name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
@@ -448,8 +452,9 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> })
|
||||
var selectedDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
|
||||
var weekOffset by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Load watchlist and fetch calendar from TMDB
|
||||
LaunchedEffect(Unit) {
|
||||
// P1: Key on watchlist size so calendar re-fetches when watchlist changes
|
||||
var watchlistVersion by remember { mutableIntStateOf(0) }
|
||||
LaunchedEffect(watchlistVersion) {
|
||||
watchlist = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||
WatchlistRepository.getAllWatchlistFromDb()
|
||||
}
|
||||
@@ -498,7 +503,14 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> })
|
||||
LumaLoading(label = stringResource(Res.string.loading_search))
|
||||
}
|
||||
} else {
|
||||
val filteredEpisodes = filterCalendarEpisodes(calendarEpisodes, selectedDate, today)
|
||||
// P2: Remember filtered + grouped results to avoid recomputation on unrelated state changes
|
||||
val filteredEpisodes = remember(calendarEpisodes, selectedDate, today) {
|
||||
filterCalendarEpisodes(calendarEpisodes, selectedDate, today)
|
||||
}
|
||||
// P2: Pre-compute watchlist lookup map — O(1) per episode instead of O(n) find
|
||||
val watchByTmdbId = remember(watchlist) {
|
||||
watchlist.associateBy { it.tmdbId?.toIntOrNull() }
|
||||
}
|
||||
if (filteredEpisodes.isEmpty()) {
|
||||
LumaEmptyStateContainer(
|
||||
icon = LumaIcon.Calendar,
|
||||
@@ -507,7 +519,9 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> })
|
||||
verticalOffset = (-60).dp,
|
||||
)
|
||||
} else {
|
||||
val groupedByDate = filteredEpisodes.groupBy { it.airDate }.toSortedMap()
|
||||
val groupedByDate = remember(filteredEpisodes) {
|
||||
filteredEpisodes.groupBy { it.airDate }.toSortedMap()
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
@@ -526,7 +540,7 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> })
|
||||
)
|
||||
}
|
||||
items(items = episodes, key = { "${it.tmdbId}_${it.seasonNumber}_${it.episodeNumber}" }) { episode ->
|
||||
val matchedWatch = watchlist.find { it.tmdbId?.toIntOrNull() == episode.tmdbId }
|
||||
val matchedWatch = watchByTmdbId[episode.tmdbId]
|
||||
CalendarEpisodeCard(
|
||||
episode = episode,
|
||||
onClick = {
|
||||
|
||||
@@ -156,24 +156,22 @@ private data class CachedSearchResults(
|
||||
)
|
||||
|
||||
private object SearchResultsCache {
|
||||
private var cachedQuery: String = ""
|
||||
private var cachedResults: CachedSearchResults? = null
|
||||
|
||||
@Synchronized
|
||||
fun get(query: String): CachedSearchResults? {
|
||||
return if (query == cachedQuery) cachedResults else null
|
||||
// P1: LruCache(5) instead of single-slot — allows switching between recent searches without re-fetching
|
||||
private val cache = object : android.util.LruCache<String, CachedSearchResults>(5) {
|
||||
override fun sizeOf(key: String, value: CachedSearchResults) = 1
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun get(query: String): CachedSearchResults? = cache.get(query)
|
||||
|
||||
@Synchronized
|
||||
fun put(query: String, results: CachedSearchResults) {
|
||||
cachedQuery = query
|
||||
cachedResults = results
|
||||
cache.put(query, results)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() {
|
||||
cachedQuery = ""
|
||||
cachedResults = null
|
||||
cache.evictAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -396,47 +396,30 @@ fun VideoPlayerScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// P0: Consolidate 4 polling loops (wifi/battery/format/speed) into 1 combined loop
|
||||
LaunchedEffect(player) {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
|
||||
while (true) {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
// WiFi check (replaces dedicated 2s loop)
|
||||
isWifi = cm?.activeNetwork?.let { net ->
|
||||
cm.getNetworkCapabilities(net)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
|
||||
} ?: false
|
||||
delay(2000)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
var batteryManager: BatteryManager? = null
|
||||
while (true) {
|
||||
if (batteryManager == null) {
|
||||
batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
|
||||
}
|
||||
// Battery check (replaces dedicated 1s loop)
|
||||
batteryManager?.let {
|
||||
batteryPercent = it.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||
val status = it.getIntProperty(BatteryManager.BATTERY_PROPERTY_STATUS)
|
||||
isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(player) {
|
||||
while (true) {
|
||||
// Video format check (replaces dedicated 1s loop)
|
||||
val vf = player.videoFormat
|
||||
if (vf != null) {
|
||||
videoCodecText = formatVideoCodec(vf.sampleMimeType)
|
||||
videoBitrateText = formatBitrate(vf.bitrate)
|
||||
videoFrameRateText = if (vf.frameRate > 0f) String.format("%.0f fps", vf.frameRate) else ""
|
||||
}
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
val speed = speedMonitor.currentBytesPerSec()
|
||||
networkSpeed = speed
|
||||
// Network speed (replaces dedicated 1s loop)
|
||||
networkSpeed = speedMonitor.currentBytesPerSec()
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
@@ -1059,7 +1042,7 @@ fun VideoPlayerScreen(
|
||||
totalDurationMs = player.duration
|
||||
if (!isSeeking && seekPreviewPositionMs == null) {
|
||||
currentPositionMs = player.currentPosition
|
||||
progress = player.currentPosition.toFloat() / player.duration.toFloat()
|
||||
// P0: Don't write outer 'progress' here — already handled by outer loop at 250ms
|
||||
val b = (player.bufferedPosition.toFloat() / player.duration).coerceIn(0f, 1f)
|
||||
bufferProgress = maxOf(b, progress)
|
||||
}
|
||||
|
||||
@@ -583,11 +583,13 @@ fun LiquidGlassPopupOverlay(
|
||||
)
|
||||
vibrancy()
|
||||
blur(if (GlassTokens.isDark) 8.dp.toPx() else 16.dp.toPx())
|
||||
lens(
|
||||
refractionHeight = 8.dp.toPx() * rawProgress,
|
||||
refractionAmount = 18.dp.toPx() * rawProgress,
|
||||
depthEffect = true,
|
||||
)
|
||||
if (LumaGlassRuntime.useLensEffect) {
|
||||
lens(
|
||||
refractionHeight = 8.dp.toPx() * rawProgress,
|
||||
refractionAmount = 18.dp.toPx() * rawProgress,
|
||||
depthEffect = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
highlight = { Highlight.Plain.copy(alpha = 0.58f) },
|
||||
shadow = { Shadow(alpha = 0.18f) },
|
||||
|
||||
@@ -86,8 +86,15 @@ internal object LumaGlassRuntime {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
}
|
||||
|
||||
// P1: Set during Application.onCreate to disable lens on low-end devices
|
||||
var isLowEndDevice: Boolean = false
|
||||
internal set
|
||||
|
||||
val useNativeBackdrop: Boolean
|
||||
get() = AppTheme.glassMode == com.example.myapplication.ui.GlassMode.Liquid && isBackdropSafe
|
||||
|
||||
val useLensEffect: Boolean
|
||||
get() = useNativeBackdrop && !isLowEndDevice
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -335,7 +342,7 @@ fun Modifier.lumaGlass(
|
||||
)
|
||||
vibrancy()
|
||||
blur((blurOverride ?: spec.blur).toPx())
|
||||
if (level == LumaGlassLevel.Floating || level == LumaGlassLevel.Interactive) {
|
||||
if ((level == LumaGlassLevel.Floating || level == LumaGlassLevel.Interactive) && LumaGlassRuntime.useLensEffect) {
|
||||
lens(
|
||||
refractionHeight = if (level == LumaGlassLevel.Interactive) 8.dp.toPx() else 6.dp.toPx(),
|
||||
refractionAmount = if (level == LumaGlassLevel.Interactive) 18.dp.toPx() else 12.dp.toPx(),
|
||||
|
||||
Reference in new issue
Block a user