diff --git a/app/src/main/kotlin/com/example/myapplication/LumaApplication.kt b/app/src/main/kotlin/com/example/myapplication/LumaApplication.kt index 0ea9dde..f0a6d7e 100644 --- a/app/src/main/kotlin/com/example/myapplication/LumaApplication.kt +++ b/app/src/main/kotlin/com/example/myapplication/LumaApplication.kt @@ -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 { 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 f8c7595..188db47 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/EmbyRepository.kt @@ -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 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 b879947..a548e13 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/ServerRepository.kt @@ -21,32 +21,37 @@ object ServerRepository { val servers: StateFlow> = _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) { 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 b39f201..d661850 100644 --- a/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt +++ b/app/src/main/kotlin/com/example/myapplication/data/TmdbRepository.kt @@ -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): List = 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() diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt index 768c7ca..d3e95c3 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -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, ) } 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 e769610..0124e63 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -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, 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, 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(), diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt index 872fb0b..45f3a94 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt index 77c097f..80e9016 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/MediaDetailScreen.kt @@ -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) { diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index 4a43a35..cdab733 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -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(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 = { diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt index b08a799..557c511 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt @@ -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(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() } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt index 26cebd7..5df589f 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/VideoPlayerScreen.kt @@ -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) } 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 d34cc58..774a449 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -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) }, diff --git a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt index f61ebcf..e95234b 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/design/LumaDesignSystem.kt @@ -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(),