diff --git a/android/app/src/main/java/tv/vyla/app/MainActivity.kt b/android/app/src/main/java/tv/vyla/app/MainActivity.kt index ab07458..4c5d2d1 100644 --- a/android/app/src/main/java/tv/vyla/app/MainActivity.kt +++ b/android/app/src/main/java/tv/vyla/app/MainActivity.kt @@ -24,6 +24,7 @@ import tv.vyla.app.ui.components.NavDestination import tv.vyla.app.ui.components.Sidebar import tv.vyla.app.ui.screens.* import tv.vyla.app.ui.theme.VylaTvTheme +import androidx.compose.ui.draw.drawWithContent class MainActivity : ComponentActivity() { @@ -81,10 +82,12 @@ class MainActivity : ComponentActivity() { ) } + val contentCovered = playingItem != null || activeDetailItem != null Box( modifier = Modifier .fillMaxSize() .focusRequester(contentFocusRequester) + .drawWithContent { if (!contentCovered) drawContent() } ) { when (currentDestination) { NavDestination.DISCOVERY, diff --git a/android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt b/android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt index 68a5830..dc01e65 100644 --- a/android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt +++ b/android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt @@ -308,7 +308,14 @@ object TmdbApi { return list } + private const val CACHE_TTL_MS = 10 * 60 * 1000L + private val responseCache = android.util.LruCache>(80) + private suspend fun fetchJson(path: String): JSONObject? = withContext(Dispatchers.IO) { + val now = System.currentTimeMillis() + responseCache.get(path)?.let { (at, cached) -> + if (now - at < CACHE_TTL_MS) return@withContext cached + } val separator = if (path.contains("?")) "&" else "?" val url = "$BASE_URL$path${separator}api_key=$API_KEY&language=en-US" val req = Request.Builder().url(url).build() @@ -316,10 +323,12 @@ object TmdbApi { client.newCall(req).execute().use { resp -> if (resp.isSuccessful) { val body = resp.body?.string() - if (!body.isNullOrEmpty()) JSONObject(body) else null + if (!body.isNullOrEmpty()) { + JSONObject(body).also { responseCache.put(path, now to it) } + } else null } else null } - } catch (_: IOException) { + } catch (_: Exception) { null } } diff --git a/android/app/src/main/java/tv/vyla/app/proxy/LocalProxy.kt b/android/app/src/main/java/tv/vyla/app/proxy/LocalProxy.kt index 65eec64..b9d275c 100644 --- a/android/app/src/main/java/tv/vyla/app/proxy/LocalProxy.kt +++ b/android/app/src/main/java/tv/vyla/app/proxy/LocalProxy.kt @@ -13,6 +13,8 @@ import java.io.InputStream import java.net.URLEncoder import java.util.concurrent.TimeUnit import java.util.regex.Pattern +import java.util.Collections +import java.util.concurrent.Executors class LocalProxy(port: Int = PORT) : NanoHTTPD("127.0.0.1", port) { @@ -73,6 +75,28 @@ class LocalProxy(port: Int = PORT) : NanoHTTPD("127.0.0.1", port) { override fun getDescription(): String = "$code Status" } + private class PoolRunner : AsyncRunner { + private val pool = Executors.newCachedThreadPool() + private val running = Collections.synchronizedList(ArrayList()) + + override fun closeAll() { + synchronized(running) { ArrayList(running).forEach { it.close() } } + } + + override fun closed(clientHandler: ClientHandler) { + running.remove(clientHandler) + } + + override fun exec(clientHandler: ClientHandler) { + running.add(clientHandler) + pool.execute(clientHandler) + } + } + + init { + setAsyncRunner(PoolRunner()) + } + override fun serve(session: IHTTPSession): Response { val r: Response = when { session.method == Method.OPTIONS -> newFixedLengthResponse(Code(204), "text/plain", "") diff --git a/android/app/src/main/java/tv/vyla/app/ui/components/AmbientBackdrop.kt b/android/app/src/main/java/tv/vyla/app/ui/components/AmbientBackdrop.kt new file mode 100644 index 0000000..17c0505 --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/ui/components/AmbientBackdrop.kt @@ -0,0 +1,70 @@ +package tv.vyla.app.ui.components + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil.compose.AsyncImage +import coil.request.ImageRequest + +val HeroFadeStops: Array> = arrayOf( + 0f to 1f, + 0.38f to 1f, + 0.50f to 0.92f, + 0.62f to 0.70f, + 0.74f to 0.42f, + 0.86f to 0.16f, + 1f to 0f +) + +@Composable +fun AmbientBackdrop(url: String?, modifier: Modifier = Modifier, durationMs: Int = 600) { + val context = LocalContext.current + Crossfade( + targetState = url, + modifier = modifier, + animationSpec = tween(durationMs), + label = "ambient_backdrop" + ) { target -> + if (!target.isNullOrEmpty()) { + val request = remember(target) { + ImageRequest.Builder(context) + .data(target) + .size(48, 27) + .allowHardware(false) + .build() + } + AsyncImage( + model = request, + contentDescription = null, + contentScale = ContentScale.Crop, + filterQuality = FilterQuality.Medium, + modifier = Modifier.fillMaxSize() + ) + } + } +} + +fun Modifier.verticalFadeMask(vararg stops: Pair): Modifier = + this + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) + .drawWithCache { + val brush = Brush.verticalGradient( + *Array(stops.size) { stops[it].first to Color.Black.copy(alpha = stops[it].second) } + ) + onDrawWithContent { + drawContent() + drawRect(brush = brush, blendMode = BlendMode.DstIn) + } + } \ No newline at end of file diff --git a/android/app/src/main/java/tv/vyla/app/ui/components/MediaCard.kt b/android/app/src/main/java/tv/vyla/app/ui/components/MediaCard.kt index 820f225..dc8c432 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/components/MediaCard.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/components/MediaCard.kt @@ -26,6 +26,7 @@ import coil.compose.AsyncImage import tv.vyla.app.model.MediaItem import tv.vyla.app.ui.theme.VylaCardBackground import tv.vyla.app.ui.theme.VylaTextPrimary +import androidx.compose.ui.graphics.graphicsLayer @Composable fun MediaCard( @@ -48,7 +49,10 @@ fun MediaCard( modifier = modifier .width(width.dp) .aspectRatio(2f / 3f) - .scale(scale) + .graphicsLayer { + scaleX = scale + scaleY = scale + } .clip(shape) .background(VylaCardBackground) .then( diff --git a/android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt b/android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt index 1d5fbd8..814e69e 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt @@ -88,7 +88,7 @@ fun VideoTintSampler(active: Boolean, viewProvider: () -> View?) { bitmap.getPixels(pixels, 0, GRID_W, 0, 0, GRID_W, GRID_H) VideoTintState.frame = VideoFrameColors(pixels.copyOf(), GRID_W, GRID_H) } - delay(400) + delay(700) } } } @@ -163,7 +163,7 @@ fun BackdropTintSampler(url: String?) { } val request = ImageRequest.Builder(context) .data(url) - .size(96, 54) + .size(48, 27) .allowHardware(false) .build() val result = context.imageLoader.execute(request) diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt index 8083479..62b01bb 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt @@ -57,6 +57,10 @@ import kotlinx.coroutines.withTimeoutOrNull import tv.vyla.app.ui.components.ContentFocusBridge import tv.vyla.app.ui.components.BackdropTintSampler import tv.vyla.app.ui.components.videoTint +import androidx.compose.foundation.lazy.itemsIndexed +import tv.vyla.app.ui.components.AmbientBackdrop +import tv.vyla.app.ui.components.HeroFadeStops +import tv.vyla.app.ui.components.verticalFadeMask data class GenreRow( @@ -93,17 +97,24 @@ fun BrowseScreen( val listState = rememberLazyListState() val cardRequesters = remember { mutableMapOf() } var lastFocusedKey by remember { mutableStateOf(null) } + var ambientUrl by remember { mutableStateOf(null) } + var lastRowIndex by remember { mutableIntStateOf(-1) } fun updateHero(item: MediaItem) { heroJob?.cancel() + if (heroItem?.id == item.id && heroItem?.mediaType == item.mediaType) return heroJob = coroutineScope.launch { - delay(130) + delay(200) heroItem = item + ambientUrl = item.ambientBackdropUrl heroLogoUrl = TmdbApi.getLogo(item.mediaType, item.id) } } + fun pinAmbientToHero() { + heroItem?.let { ambientUrl = it.ambientBackdropUrl } + } suspend fun loadMoreRows(count: Int) { - if (isLoadingMore) return + if (isLoadingMore || rows.size >= 48) return isLoadingMore = true val generation = loadGeneration val activeMode = currentMode @@ -147,6 +158,7 @@ suspend fun loadMoreRows(count: Int) { val first = rows[0].items.firstOrNull() if (first != null) { heroItem = first + ambientUrl = first.ambientBackdropUrl coroutineScope.launch { heroLogoUrl = TmdbApi.getLogo(first.mediaType, first.id) } @@ -191,6 +203,8 @@ LaunchedEffect(mode) { rows.clear() rowIdx = 0 heroItem = null + ambientUrl = null + lastRowIndex = -1 heroLogoUrl = null movieGenres.clear() tvGenres.clear() @@ -215,22 +229,11 @@ LaunchedEffect(mode) { BackdropTintSampler(heroItem?.ambientBackdropUrl) Box(modifier = modifier.fillMaxSize().background(VylaBackground)) { - Crossfade( - targetState = heroItem?.ambientBackdropUrl, - animationSpec = tween(700), - label = "ambient_bg" - ) { url -> - if (!url.isNullOrEmpty()) { - AsyncImage( - model = url, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .blur(50.dp) - ) - } - } + AmbientBackdrop( + url = ambientUrl, + modifier = Modifier.fillMaxSize(), + durationMs = 700 + ) Box( modifier = Modifier .fillMaxSize() @@ -254,6 +257,8 @@ LaunchedEffect(mode) { playButtonFocusRequester = playButtonFocusRequester, onFocusedTop = { lastFocusedKey = null + lastRowIndex = -1 + pinAmbientToHero() coroutineScope.launch { listState.animateScrollToItem(0) } @@ -268,9 +273,8 @@ LaunchedEffect(mode) { } ) } - items(rows) { row -> + itemsIndexed(rows, key = { i, row -> "$i:${row.title}" }) { rowIndex, row -> val rowState = rememberLazyListState() - val rowIndex = rows.indexOf(row) Column(modifier = Modifier.padding(vertical = 10.dp)) { Text( text = row.title, @@ -284,10 +288,9 @@ LaunchedEffect(mode) { contentPadding = PaddingValues(horizontal = 78.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - items(row.items) { item -> - val index = row.items.indexOf(item) - val isFirstInRow = row.items.firstOrNull() == item - val cardKey = "${row.title}:${item.id}" + itemsIndexed(row.items, key = { _, it -> it.id }) { index, item -> + val isFirstInRow = index == 0 + val cardKey = "$rowIndex:${item.id}" val cardRequester = cardRequesters.getOrPut(cardKey) { FocusRequester() } MediaCard( item = item, @@ -302,11 +305,16 @@ LaunchedEffect(mode) { index, -(viewportWidth / 2 - 68) ) - val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset - listState.animateScrollToItem( - rowIndex + 1, - -(viewportHeight / 2 - 100) - ) + } + if (lastRowIndex != rowIndex) { + lastRowIndex = rowIndex + coroutineScope.launch { + val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset + listState.animateScrollToItem( + rowIndex + 1, + -(viewportHeight / 2 - 100) + ) + } } }, modifier = Modifier.focusRequester(cardRequester).onKeyEvent { event -> @@ -345,39 +353,32 @@ private fun HeroSection( .fillMaxWidth() .height(460.dp) ) { - val backdropUrl = item?.backdropUrl - if (!backdropUrl.isNullOrEmpty()) { - AsyncImage( - model = backdropUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) - .drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient( - 0.0f to Color.Black, - 0.45f to Color.Black, - 1.0f to Color.Transparent - ), - blendMode = BlendMode.DstIn - ) - } - ) - } Box( modifier = Modifier .fillMaxSize() - .background( - Brush.horizontalGradient( - 0.0f to Color(0xCC000000), - 0.40f to Color(0x59000000), - 0.70f to Color.Transparent - ) + .verticalFadeMask(*HeroFadeStops) + ) { + val backdropUrl = item?.backdropUrl + if (!backdropUrl.isNullOrEmpty()) { + AsyncImage( + model = backdropUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() ) - ) + } + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + 0.0f to Color(0xCC000000), + 0.40f to Color(0x59000000), + 0.70f to Color.Transparent + ) + ) + ) + } Column( modifier = Modifier .align(Alignment.BottomStart) @@ -503,7 +504,10 @@ private fun HeroPillButton( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier - .scale(scale) + .graphicsLayer { + scaleX = scale + scaleY = scale + } .height(48.dp) .clip(RoundedCornerShape(24.dp)) .then( diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt index c6d4ff8..6e3558d 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt @@ -109,6 +109,12 @@ import tv.vyla.app.model.* import tv.vyla.app.ui.components.MediaCard import tv.vyla.app.ui.theme.* +import androidx.compose.ui.graphics.FilterQuality +import coil.request.ImageRequest +import tv.vyla.app.ui.components.AmbientBackdrop +import tv.vyla.app.ui.components.HeroFadeStops +import tv.vyla.app.ui.components.verticalFadeMask + @Composable fun DetailScreen( @@ -141,7 +147,6 @@ fun DetailScreen( if (d != null && d.seasons.isNotEmpty()) { val initialSeason = d.seasons.firstOrNull { it.seasonNumber == 1 } ?: d.seasons.first() selectedSeason = initialSeason - seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, initialSeason.seasonNumber) } delay(150) try { @@ -190,42 +195,23 @@ fun DetailScreen( } } Box(modifier = modifier.fillMaxSize().background(Color(0xFF07070A))) { - val bg = item.ambientBackdropUrl - if (!bg.isNullOrEmpty()) { - AsyncImage( - model = bg, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .blur(60.dp) - ) - } + AmbientBackdrop( + url = item.ambientBackdropUrl, + modifier = Modifier.fillMaxSize() + ) val backdrop = item.backdropUrl if (!backdrop.isNullOrEmpty()) { Box( modifier = Modifier .fillMaxWidth() .fillMaxHeight(0.82f) + .verticalFadeMask(*HeroFadeStops) ) { AsyncImage( model = backdrop, contentDescription = null, contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) - .drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient( - 0.0f to Color.Black, - 0.45f to Color.Black, - 1.0f to Color.Transparent - ), - blendMode = BlendMode.DstIn - ) - } + modifier = Modifier.fillMaxSize() ) } } @@ -952,15 +938,27 @@ private fun DetailTrailerCard( onFocused: (() -> Unit)? = null, onClick: () -> Unit ) { + val context = LocalContext.current var isFocused by remember { mutableStateOf(false) } val scale by animateFloatAsState(if (isFocused) 1.05f else 1.0f, label = "tr_scale") + val shape = RoundedCornerShape(16.dp) + val tinyRequest = remember(trailer.key) { + ImageRequest.Builder(context) + .data(trailer.thumbnailUrl) + .size(32, 18) + .allowHardware(false) + .build() + } Box( modifier = Modifier .width(280.dp) - .scale(scale) - .clip(RoundedCornerShape(16.dp)) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .clip(shape) .background(Color(0xFF101016)) - .then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(16.dp)) else Modifier) + .then(if (isFocused) Modifier.border(2.dp, Color.White, shape) else Modifier) .onFocusChanged { isFocused = it.isFocused if (it.isFocused) onFocused?.invoke() @@ -971,11 +969,35 @@ private fun DetailTrailerCard( indication = null ) { onClick() } ) { + AsyncImage( + model = tinyRequest, + contentDescription = null, + contentScale = ContentScale.Crop, + filterQuality = FilterQuality.Medium, + modifier = Modifier.matchParentSize() + ) + Box( + modifier = Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + 0.0f to Color(0x26000000), + 1.0f to Color(0x80000000) + ) + ) + ) Column { Box( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) + .verticalFadeMask( + 0f to 1f, + 0.55f to 1f, + 0.72f to 0.72f, + 0.86f to 0.30f, + 1f to 0f + ) ) { AsyncImage( model = trailer.thumbnailUrl, @@ -998,62 +1020,43 @@ private fun DetailTrailerCard( modifier = Modifier.size(26.dp) ) } - Box( - modifier = Modifier - .fillMaxWidth() - .height(40.dp) - .align(Alignment.BottomCenter) - .background( - Brush.verticalGradient( - 0.0f to Color.Transparent, - 1.0f to Color(0xE6101016) - ) - ) - ) } - Box( + Column( modifier = Modifier .fillMaxWidth() - .height(76.dp) + .padding(start = 14.dp, end = 14.dp, bottom = 14.dp) ) { - AsyncImage( - model = trailer.thumbnailUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .blur(36.dp) + Text( + text = trailer.type.uppercase(), + color = Color(0xB3FFFFFF), + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 1.2.sp ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - 0.0f to Color(0xE6101016), - 0.35f to Color(0x99101016), - 1.0f to Color(0xF2101016) - ) - ) + Text( + text = trailer.name, + color = Color.White, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp) ) - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalArrangement = Arrangement.Center + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 8.dp) ) { - Text( - text = trailer.name, - color = Color(0xFFF2F2F5), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = null, + tint = Color(0xCCFFFFFF), + modifier = Modifier.size(14.dp) ) Text( - text = "▶ ${trailer.type}", - color = Color(0xFFD0D0DA), + text = trailer.site, + color = Color(0xCCFFFFFF), fontSize = 12.sp, - modifier = Modifier.padding(top = 4.dp) + modifier = Modifier.padding(start = 4.dp) ) } } diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/LibraryScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/LibraryScreen.kt index 1e2c925..df5f0d0 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/screens/LibraryScreen.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/LibraryScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Brush import androidx.compose.ui.layout.ContentScale import coil.compose.AsyncImage +import tv.vyla.app.ui.components.AmbientBackdrop @Composable fun LibraryScreen( @@ -37,22 +38,11 @@ fun LibraryScreen( ) { var ambientUrl by remember(items) { mutableStateOf(items.firstOrNull()?.ambientBackdropUrl) } Box(modifier = modifier.fillMaxSize().background(VylaBackground)) { - Crossfade( - targetState = ambientUrl, - animationSpec = tween(700), - label = "library_ambient" - ) { url -> - if (!url.isNullOrEmpty()) { - AsyncImage( - model = url, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .blur(50.dp) - ) - } - } + AmbientBackdrop( + url = ambientUrl, + modifier = Modifier.fillMaxSize(), + durationMs = 700 + ) Box( modifier = Modifier .fillMaxSize() diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt index b78787d..7ea8c9d 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt @@ -178,17 +178,22 @@ fun PlayerScreen( } LaunchedEffect(exoPlayer) { while (true) { - currentPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L) - bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0L) + val pos = exoPlayer.currentPosition.coerceAtLeast(0L) val dur = exoPlayer.duration - if (dur > 0L) durationMs = dur - val currentSec = currentPositionMs / 1000.0 + if (dur > 0L && dur != durationMs) durationMs = dur + val nearEnd = dur > 0L && dur - pos <= 300000L + if (areControlsVisible || nearEnd) { + if (pos != currentPositionMs) currentPositionMs = pos + val buffered = exoPlayer.bufferedPosition.coerceAtLeast(0L) + if (buffered != bufferedPositionMs) bufferedPositionMs = buffered + } + val currentSec = pos / 1000.0 val seg = segments.firstOrNull { s -> val endSec = s.end currentSec >= s.start && (endSec == null || currentSec < endSec) } - activeSegment = seg - delay(300) + if (seg != activeSegment) activeSegment = seg + delay(if (areControlsVisible) 300L else 600L) } } DisposableEffect(Unit) { diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt index d38d6ba..e638e4c 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt @@ -47,6 +47,9 @@ import tv.vyla.app.model.MediaItem import tv.vyla.app.ui.theme.* import androidx.compose.ui.graphics.vector.ImageVector import tv.vyla.app.ui.components.SearchIcons +import tv.vyla.app.ui.components.AmbientBackdrop +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.graphicsLayer @Composable @@ -131,22 +134,10 @@ fun SearchScreen( false } ) { - Crossfade( - targetState = focusedItem?.ambientBackdropUrl, - animationSpec = tween(600), - label = "search_bg" - ) { url -> - if (!url.isNullOrEmpty()) { - AsyncImage( - model = url, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxSize() - .blur(70.dp) - ) - } - } + AmbientBackdrop( + url = focusedItem?.ambientBackdropUrl, + modifier = Modifier.fillMaxSize() + ) Box( modifier = Modifier .fillMaxSize() @@ -192,7 +183,7 @@ fun SearchScreen( .padding(start = 6.dp) .width(3.dp) .height(30.dp) - .background(Color.White.copy(alpha = cursorAlpha)) + .drawBehind { drawRect(Color.White, alpha = cursorAlpha) } ) } } @@ -566,7 +557,10 @@ private fun SearchMediaCard( Column( modifier = Modifier .width(140.dp) - .scale(scale) + .graphicsLayer { + scaleX = scale + scaleY = scale + } .offset(y = if (isFocused) (-3).dp else 0.dp) .onFocusChanged { isFocused = it.isFocused diff --git a/android/app/src/main/java/tv/vyla/app/ui/theme/Theme.kt b/android/app/src/main/java/tv/vyla/app/ui/theme/Theme.kt index 495ddc5..b0a79a6 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/theme/Theme.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/theme/Theme.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.darkColorScheme +import androidx.compose.ui.graphics.graphicsLayer val VylaBackground = Color(0xFF07070A) val VylaCardBackground = Color(0xFF1B1B24) @@ -60,7 +61,10 @@ fun Modifier.tvFocusable( ) return this - .scale(scale) + .graphicsLayer { + scaleX = scale + scaleY = scale + } .then( if (isFocused) { Modifier