3 Commits

Author SHA1 Message Date
44a6e3cef9 1.0.5 2026-10-05 15:47:25 -07:00
9c0c5bf17d tweaked ui 2026-10-05 15:47:10 -07:00
01e532a6a5 v1.0.4 2026-10-05 13:22:52 -07:00
18 changed files with 311 additions and 187 deletions

View File

@@ -1,3 +1,10 @@
// Currently active sources reported on 6:18:22 PM PST 9/13/2026
// None Anime Tested: 936075
// Anime Tested: 37854
// Fshare/Fsonic Tested: 155 ( they don't have Michael )
export const SOURCES = [ export const SOURCES = [
{ {
key: '123anime', key: '123anime',
@@ -232,7 +239,7 @@ export const SOURCES = [
retries: 1, retries: 1,
multiUrl: true, multiUrl: true,
skipProxy: true, skipProxy: true,
disabled: false, disabled: false, // Enabled, but only returns streams when a manifest is provided
}, },
{ {

View File

@@ -1,3 +1,8 @@
import { fetchJson } from '../utils/helpers.js';
// const PENGU_CONFIG = '%7B%22source_4khdhub%22%3A%22on%22%2C%22source_moviebox%22%3A%22on%22%2C%22source_moviesdrives%22%3A%22on%22%2C%22source_vaplayer%22%3A%22on%22%2C%22source_hdghartv%22%3A%22on%22%2C%22res_2160%22%3A%22on%22%2C%22res_1080%22%3A%22on%22%2C%22res_720%22%3A%22on%22%2C%22disable_direct%22%3A%22on%22%7D';
// const BASE_URL = `https://pengu.uk/${PENGU_CONFIG}`;
async function getImdbId(tmdbApiKey, tmdbId, s, e) { async function getImdbId(tmdbApiKey, tmdbId, s, e) {
const key = tmdbApiKey; const key = tmdbApiKey;
if (!key) return null; if (!key) return null;

View File

@@ -1,3 +1,5 @@
// im being salty
import { USER_AGENT, fetchJson } from '../utils/helpers.js'; import { USER_AGENT, fetchJson } from '../utils/helpers.js';
const C_ARRAY = [ const C_ARRAY = [

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.components.Sidebar
import tv.vyla.app.ui.screens.* import tv.vyla.app.ui.screens.*
import tv.vyla.app.ui.theme.VylaTvTheme import tv.vyla.app.ui.theme.VylaTvTheme
import androidx.compose.ui.draw.drawWithContent
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -81,10 +82,12 @@ class MainActivity : ComponentActivity() {
) )
} }
val contentCovered = playingItem != null || activeDetailItem != null
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.focusRequester(contentFocusRequester) .focusRequester(contentFocusRequester)
.drawWithContent { if (!contentCovered) drawContent() }
) { ) {
when (currentDestination) { when (currentDestination) {
NavDestination.DISCOVERY, NavDestination.DISCOVERY,

View File

@@ -308,7 +308,14 @@ object TmdbApi {
return list 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) { 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 separator = if (path.contains("?")) "&" else "?"
val url = "$BASE_URL$path${separator}api_key=$API_KEY&language=en-US" val url = "$BASE_URL$path${separator}api_key=$API_KEY&language=en-US"
val req = Request.Builder().url(url).build() val req = Request.Builder().url(url).build()
@@ -316,10 +323,12 @@ object TmdbApi {
client.newCall(req).execute().use { resp -> client.newCall(req).execute().use { resp ->
if (resp.isSuccessful) { if (resp.isSuccessful) {
val body = resp.body?.string() 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 } else null
} }
} catch (_: IOException) { } catch (_: Exception) {
null null
} }
} }

View File

@@ -13,6 +13,8 @@ import java.io.InputStream
import java.net.URLEncoder import java.net.URLEncoder
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.regex.Pattern 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) { 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" 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 { override fun serve(session: IHTTPSession): Response {
val r: Response = when { val r: Response = when {
session.method == Method.OPTIONS -> newFixedLengthResponse(Code(204), "text/plain", "") 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.model.MediaItem
import tv.vyla.app.ui.theme.VylaCardBackground import tv.vyla.app.ui.theme.VylaCardBackground
import tv.vyla.app.ui.theme.VylaTextPrimary import tv.vyla.app.ui.theme.VylaTextPrimary
import androidx.compose.ui.graphics.graphicsLayer
@Composable @Composable
fun MediaCard( fun MediaCard(
@@ -48,7 +49,10 @@ fun MediaCard(
modifier = modifier modifier = modifier
.width(width.dp) .width(width.dp)
.aspectRatio(2f / 3f) .aspectRatio(2f / 3f)
.scale(scale) .graphicsLayer {
scaleX = scale
scaleY = scale
}
.clip(shape) .clip(shape)
.background(VylaCardBackground) .background(VylaCardBackground)
.then( .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) bitmap.getPixels(pixels, 0, GRID_W, 0, 0, GRID_W, GRID_H)
VideoTintState.frame = VideoFrameColors(pixels.copyOf(), 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) val request = ImageRequest.Builder(context)
.data(url) .data(url)
.size(96, 54) .size(48, 27)
.allowHardware(false) .allowHardware(false)
.build() .build()
val result = context.imageLoader.execute(request) 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.ContentFocusBridge
import tv.vyla.app.ui.components.BackdropTintSampler import tv.vyla.app.ui.components.BackdropTintSampler
import tv.vyla.app.ui.components.videoTint 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( data class GenreRow(
@@ -93,17 +97,24 @@ fun BrowseScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val cardRequesters = remember { mutableMapOf<String, FocusRequester>() } val cardRequesters = remember { mutableMapOf<String, FocusRequester>() }
var lastFocusedKey by remember { mutableStateOf<String?>(null) } var lastFocusedKey by remember { mutableStateOf<String?>(null) }
var ambientUrl by remember { mutableStateOf<String?>(null) }
var lastRowIndex by remember { mutableIntStateOf(-1) }
fun updateHero(item: MediaItem) { fun updateHero(item: MediaItem) {
heroJob?.cancel() heroJob?.cancel()
if (heroItem?.id == item.id && heroItem?.mediaType == item.mediaType) return
heroJob = coroutineScope.launch { heroJob = coroutineScope.launch {
delay(130) delay(200)
heroItem = item heroItem = item
ambientUrl = item.ambientBackdropUrl
heroLogoUrl = TmdbApi.getLogo(item.mediaType, item.id) heroLogoUrl = TmdbApi.getLogo(item.mediaType, item.id)
} }
} }
fun pinAmbientToHero() {
heroItem?.let { ambientUrl = it.ambientBackdropUrl }
}
suspend fun loadMoreRows(count: Int) { suspend fun loadMoreRows(count: Int) {
if (isLoadingMore) return if (isLoadingMore || rows.size >= 48) return
isLoadingMore = true isLoadingMore = true
val generation = loadGeneration val generation = loadGeneration
val activeMode = currentMode val activeMode = currentMode
@@ -147,6 +158,7 @@ suspend fun loadMoreRows(count: Int) {
val first = rows[0].items.firstOrNull() val first = rows[0].items.firstOrNull()
if (first != null) { if (first != null) {
heroItem = first heroItem = first
ambientUrl = first.ambientBackdropUrl
coroutineScope.launch { coroutineScope.launch {
heroLogoUrl = TmdbApi.getLogo(first.mediaType, first.id) heroLogoUrl = TmdbApi.getLogo(first.mediaType, first.id)
} }
@@ -191,6 +203,8 @@ LaunchedEffect(mode) {
rows.clear() rows.clear()
rowIdx = 0 rowIdx = 0
heroItem = null heroItem = null
ambientUrl = null
lastRowIndex = -1
heroLogoUrl = null heroLogoUrl = null
movieGenres.clear() movieGenres.clear()
tvGenres.clear() tvGenres.clear()
@@ -215,22 +229,11 @@ LaunchedEffect(mode) {
BackdropTintSampler(heroItem?.ambientBackdropUrl) BackdropTintSampler(heroItem?.ambientBackdropUrl)
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) { Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
Crossfade( AmbientBackdrop(
targetState = heroItem?.ambientBackdropUrl, url = ambientUrl,
animationSpec = tween(700), modifier = Modifier.fillMaxSize(),
label = "ambient_bg" durationMs = 700
) { url -> )
if (!url.isNullOrEmpty()) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.blur(50.dp)
)
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -254,6 +257,8 @@ LaunchedEffect(mode) {
playButtonFocusRequester = playButtonFocusRequester, playButtonFocusRequester = playButtonFocusRequester,
onFocusedTop = { onFocusedTop = {
lastFocusedKey = null lastFocusedKey = null
lastRowIndex = -1
pinAmbientToHero()
coroutineScope.launch { coroutineScope.launch {
listState.animateScrollToItem(0) 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 rowState = rememberLazyListState()
val rowIndex = rows.indexOf(row)
Column(modifier = Modifier.padding(vertical = 10.dp)) { Column(modifier = Modifier.padding(vertical = 10.dp)) {
Text( Text(
text = row.title, text = row.title,
@@ -284,10 +288,9 @@ LaunchedEffect(mode) {
contentPadding = PaddingValues(horizontal = 78.dp), contentPadding = PaddingValues(horizontal = 78.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items(row.items) { item -> itemsIndexed(row.items, key = { _, it -> it.id }) { index, item ->
val index = row.items.indexOf(item) val isFirstInRow = index == 0
val isFirstInRow = row.items.firstOrNull() == item val cardKey = "$rowIndex:${item.id}"
val cardKey = "${row.title}:${item.id}"
val cardRequester = cardRequesters.getOrPut(cardKey) { FocusRequester() } val cardRequester = cardRequesters.getOrPut(cardKey) { FocusRequester() }
MediaCard( MediaCard(
item = item, item = item,
@@ -302,11 +305,16 @@ LaunchedEffect(mode) {
index, index,
-(viewportWidth / 2 - 68) -(viewportWidth / 2 - 68)
) )
val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset }
listState.animateScrollToItem( if (lastRowIndex != rowIndex) {
rowIndex + 1, lastRowIndex = rowIndex
-(viewportHeight / 2 - 100) coroutineScope.launch {
) val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset
listState.animateScrollToItem(
rowIndex + 1,
-(viewportHeight / 2 - 100)
)
}
} }
}, },
modifier = Modifier.focusRequester(cardRequester).onKeyEvent { event -> modifier = Modifier.focusRequester(cardRequester).onKeyEvent { event ->
@@ -345,39 +353,32 @@ private fun HeroSection(
.fillMaxWidth() .fillMaxWidth()
.height(460.dp) .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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background( .verticalFadeMask(*HeroFadeStops)
Brush.horizontalGradient( ) {
0.0f to Color(0xCC000000), val backdropUrl = item?.backdropUrl
0.40f to Color(0x59000000), if (!backdropUrl.isNullOrEmpty()) {
0.70f to Color.Transparent 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( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
@@ -503,7 +504,10 @@ private fun HeroPillButton(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier modifier = Modifier
.scale(scale) .graphicsLayer {
scaleX = scale
scaleY = scale
}
.height(48.dp) .height(48.dp)
.clip(RoundedCornerShape(24.dp)) .clip(RoundedCornerShape(24.dp))
.then( .then(

View File

@@ -109,6 +109,12 @@ import tv.vyla.app.model.*
import tv.vyla.app.ui.components.MediaCard import tv.vyla.app.ui.components.MediaCard
import tv.vyla.app.ui.theme.* 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 @Composable
fun DetailScreen( fun DetailScreen(
@@ -141,7 +147,6 @@ fun DetailScreen(
if (d != null && d.seasons.isNotEmpty()) { if (d != null && d.seasons.isNotEmpty()) {
val initialSeason = d.seasons.firstOrNull { it.seasonNumber == 1 } ?: d.seasons.first() val initialSeason = d.seasons.firstOrNull { it.seasonNumber == 1 } ?: d.seasons.first()
selectedSeason = initialSeason selectedSeason = initialSeason
seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, initialSeason.seasonNumber)
} }
delay(150) delay(150)
try { try {
@@ -190,42 +195,23 @@ fun DetailScreen(
} }
} }
Box(modifier = modifier.fillMaxSize().background(Color(0xFF07070A))) { Box(modifier = modifier.fillMaxSize().background(Color(0xFF07070A))) {
val bg = item.ambientBackdropUrl AmbientBackdrop(
if (!bg.isNullOrEmpty()) { url = item.ambientBackdropUrl,
AsyncImage( modifier = Modifier.fillMaxSize()
model = bg, )
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.blur(60.dp)
)
}
val backdrop = item.backdropUrl val backdrop = item.backdropUrl
if (!backdrop.isNullOrEmpty()) { if (!backdrop.isNullOrEmpty()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(0.82f) .fillMaxHeight(0.82f)
.verticalFadeMask(*HeroFadeStops)
) { ) {
AsyncImage( AsyncImage(
model = backdrop, model = backdrop,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier.fillMaxSize()
.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
)
}
) )
} }
} }
@@ -952,15 +938,27 @@ private fun DetailTrailerCard(
onFocused: (() -> Unit)? = null, onFocused: (() -> Unit)? = null,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val context = LocalContext.current
var isFocused by remember { mutableStateOf(false) } var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (isFocused) 1.05f else 1.0f, label = "tr_scale") 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( Box(
modifier = Modifier modifier = Modifier
.width(280.dp) .width(280.dp)
.scale(scale) .graphicsLayer {
.clip(RoundedCornerShape(16.dp)) scaleX = scale
scaleY = scale
}
.clip(shape)
.background(Color(0xFF101016)) .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 { .onFocusChanged {
isFocused = it.isFocused isFocused = it.isFocused
if (it.isFocused) onFocused?.invoke() if (it.isFocused) onFocused?.invoke()
@@ -971,11 +969,35 @@ private fun DetailTrailerCard(
indication = null indication = null
) { onClick() } ) { 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 { Column {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(16f / 9f) .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( AsyncImage(
model = trailer.thumbnailUrl, model = trailer.thumbnailUrl,
@@ -998,62 +1020,43 @@ private fun DetailTrailerCard(
modifier = Modifier.size(26.dp) 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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(76.dp) .padding(start = 14.dp, end = 14.dp, bottom = 14.dp)
) { ) {
AsyncImage( Text(
model = trailer.thumbnailUrl, text = trailer.type.uppercase(),
contentDescription = null, color = Color(0xB3FFFFFF),
contentScale = ContentScale.Crop, fontSize = 11.sp,
modifier = Modifier fontWeight = FontWeight.SemiBold,
.fillMaxSize() letterSpacing = 1.2.sp
.blur(36.dp)
) )
Box( Text(
modifier = Modifier text = trailer.name,
.fillMaxSize() color = Color.White,
.background( fontSize = 15.sp,
Brush.verticalGradient( fontWeight = FontWeight.Bold,
0.0f to Color(0xE6101016), maxLines = 1,
0.35f to Color(0x99101016), overflow = TextOverflow.Ellipsis,
1.0f to Color(0xF2101016) modifier = Modifier.padding(top = 6.dp)
)
)
) )
Column( Row(
modifier = Modifier verticalAlignment = Alignment.CenterVertically,
.fillMaxSize() modifier = Modifier.padding(top = 8.dp)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.Center
) { ) {
Text( Icon(
text = trailer.name, imageVector = Icons.Default.PlayArrow,
color = Color(0xFFF2F2F5), contentDescription = null,
fontSize = 14.sp, tint = Color(0xCCFFFFFF),
fontWeight = FontWeight.SemiBold, modifier = Modifier.size(14.dp)
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = "▶ ${trailer.type}", text = trailer.site,
color = Color(0xFFD0D0DA), color = Color(0xCCFFFFFF),
fontSize = 12.sp, 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.graphics.Brush
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage import coil.compose.AsyncImage
import tv.vyla.app.ui.components.AmbientBackdrop
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
@@ -37,22 +38,11 @@ fun LibraryScreen(
) { ) {
var ambientUrl by remember(items) { mutableStateOf(items.firstOrNull()?.ambientBackdropUrl) } var ambientUrl by remember(items) { mutableStateOf(items.firstOrNull()?.ambientBackdropUrl) }
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) { Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
Crossfade( AmbientBackdrop(
targetState = ambientUrl, url = ambientUrl,
animationSpec = tween(700), modifier = Modifier.fillMaxSize(),
label = "library_ambient" durationMs = 700
) { url -> )
if (!url.isNullOrEmpty()) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.blur(50.dp)
)
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View File

@@ -178,17 +178,22 @@ fun PlayerScreen(
} }
LaunchedEffect(exoPlayer) { LaunchedEffect(exoPlayer) {
while (true) { while (true) {
currentPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L) val pos = exoPlayer.currentPosition.coerceAtLeast(0L)
bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0L)
val dur = exoPlayer.duration val dur = exoPlayer.duration
if (dur > 0L) durationMs = dur if (dur > 0L && dur != durationMs) durationMs = dur
val currentSec = currentPositionMs / 1000.0 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 seg = segments.firstOrNull { s ->
val endSec = s.end val endSec = s.end
currentSec >= s.start && (endSec == null || currentSec < endSec) currentSec >= s.start && (endSec == null || currentSec < endSec)
} }
activeSegment = seg if (seg != activeSegment) activeSegment = seg
delay(300) delay(if (areControlsVisible) 300L else 600L)
} }
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {

View File

@@ -47,6 +47,9 @@ import tv.vyla.app.model.MediaItem
import tv.vyla.app.ui.theme.* import tv.vyla.app.ui.theme.*
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import tv.vyla.app.ui.components.SearchIcons 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 @Composable
@@ -131,22 +134,10 @@ fun SearchScreen(
false false
} }
) { ) {
Crossfade( AmbientBackdrop(
targetState = focusedItem?.ambientBackdropUrl, url = focusedItem?.ambientBackdropUrl,
animationSpec = tween(600), modifier = Modifier.fillMaxSize()
label = "search_bg" )
) { url ->
if (!url.isNullOrEmpty()) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.blur(70.dp)
)
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -192,7 +183,7 @@ fun SearchScreen(
.padding(start = 6.dp) .padding(start = 6.dp)
.width(3.dp) .width(3.dp)
.height(30.dp) .height(30.dp)
.background(Color.White.copy(alpha = cursorAlpha)) .drawBehind { drawRect(Color.White, alpha = cursorAlpha) }
) )
} }
} }
@@ -566,7 +557,10 @@ private fun SearchMediaCard(
Column( Column(
modifier = Modifier modifier = Modifier
.width(140.dp) .width(140.dp)
.scale(scale) .graphicsLayer {
scaleX = scale
scaleY = scale
}
.offset(y = if (isFocused) (-3).dp else 0.dp) .offset(y = if (isFocused) (-3).dp else 0.dp)
.onFocusChanged { .onFocusChanged {
isFocused = it.isFocused isFocused = it.isFocused

View File

@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme import androidx.tv.material3.darkColorScheme
import androidx.compose.ui.graphics.graphicsLayer
val VylaBackground = Color(0xFF07070A) val VylaBackground = Color(0xFF07070A)
val VylaCardBackground = Color(0xFF1B1B24) val VylaCardBackground = Color(0xFF1B1B24)
@@ -60,7 +61,10 @@ fun Modifier.tvFocusable(
) )
return this return this
.scale(scale) .graphicsLayer {
scaleX = scale
scaleY = scale
}
.then( .then(
if (isFocused) { if (isFocused) {
Modifier Modifier

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "tv", "name": "tv",
"version": "1.0.4", "version": "1.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tv", "name": "tv",
"version": "1.0.4", "version": "1.0.5",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@vyla-entertainment/sdk": "^1.14.0", "@vyla-entertainment/sdk": "^1.14.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "tv", "name": "tv",
"version": "1.0.4", "version": "1.0.5",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "Vyla TV", "name": "Vyla TV",
"version": "1.0.3", "version": "1.0.4",
"package": "Vyla-TV_1.0.3.apk", "package": "Vyla-TV_1.0.4.apk",
"releaseDate": "2026-10-05T18:09:19.183Z" "releaseDate": "2026-10-05T20:22:35.051Z"
} }