mirror of
https://gitlab.com/vyla-entertainment/TV.git
synced 2026-11-13 11:37:21 -05:00
tweaked ui
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user