tweaked ui

This commit is contained in:
2026-10-05 15:47:10 -07:00
parent 01e532a6a5
commit 9c0c5bf17d
12 changed files with 290 additions and 180 deletions

View File

@@ -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,

View File

@@ -308,7 +308,14 @@ object TmdbApi {
return list
}
private const val CACHE_TTL_MS = 10 * 60 * 1000L
private val responseCache = android.util.LruCache<String, Pair<Long, JSONObject>>(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
}
}

View File

@@ -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<ClientHandler>())
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", "")

View File

@@ -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<Pair<Float, Float>> = 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<Float, Float>): 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)
}
}

View File

@@ -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(

View File

@@ -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)

View File

@@ -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<String, FocusRequester>() }
var lastFocusedKey by remember { mutableStateOf<String?>(null) }
var ambientUrl by remember { mutableStateOf<String?>(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(

View File

@@ -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)
)
}
}

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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

View File

@@ -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