10 Commits

Author SHA1 Message Date
09ddbe6bae 1.0.6 2026-10-05 20:40:56 -07:00
494d8e6835 fixed watch history 2026-10-05 20:38:56 -07:00
1c3ffacccd 1.0.5 2026-10-05 15:50:17 -07:00
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
58c7889b8a 1.0.4 2026-10-05 13:21:35 -07:00
102c3bdc84 great UI update 2026-10-05 13:21:01 -07:00
112d405e75 v1.0.3 2026-10-05 11:11:30 -07:00
c3358fee44 fixed outdated capacitor 2026-10-05 11:08:26 -07:00
25 changed files with 1244 additions and 2226 deletions

5
android/.gitignore vendored
View File

@@ -89,13 +89,8 @@ lint/tmp/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml

View File

@@ -1,19 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

View File

@@ -1,25 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

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() {
@@ -47,12 +48,13 @@ class MainActivity : ComponentActivity() {
val watchLaterItems by libraryManager.watchLater.collectAsState()
val favoriteItems by libraryManager.favorites.collectAsState()
val historyItems by libraryManager.history.collectAsState()
val sidebarFocusRequester = remember { FocusRequester() }
val contentFocusRequester = remember { FocusRequester() }
BackHandler(enabled = playingItem == null && activeDetailItem == null) {
if (currentDestination != NavDestination.DISCOVERY) {
if (currentDestination != NavDestination.DISCOVERY && currentDestination != NavDestination.ANIME) {
currentDestination = NavDestination.DISCOVERY
} else {
finish()
@@ -63,11 +65,13 @@ class MainActivity : ComponentActivity() {
if (playingItem == null) {
Sidebar(
currentDestination = currentDestination,
hasHistory = historyItems.isNotEmpty(),
hasWatchLater = watchLaterItems.isNotEmpty(),
hasFavorites = favoriteItems.isNotEmpty(),
onNavigate = { dest ->
currentDestination = dest
activeDetailItem = null
contentFocusRequester.requestFocus()
},
onRequestExitSidebar = {
try {
@@ -80,19 +84,23 @@ class MainActivity : ComponentActivity() {
)
}
val contentCovered = playingItem != null || activeDetailItem != null
Box(
modifier = Modifier
.fillMaxSize()
.focusRequester(contentFocusRequester)
.drawWithContent { if (!contentCovered) drawContent() }
) {
when (currentDestination) {
NavDestination.DISCOVERY,
NavDestination.MOVIES,
NavDestination.TV_SHOWS -> {
NavDestination.TV_SHOWS,
NavDestination.ANIME -> {
BrowseScreen(
mode = currentDestination,
onOpenDetail = { item -> activeDetailItem = item },
onPlay = { item ->
libraryManager.addHistory(item)
playingItem = item
playingEpisode = null
},
@@ -113,6 +121,14 @@ class MainActivity : ComponentActivity() {
}
)
}
NavDestination.HISTORY -> {
LibraryScreen(
title = "Watch History",
items = historyItems,
onOpenDetail = { item -> activeDetailItem = item },
emptyMessage = "Nothing watched yet. Press play on a title and it shows up here."
)
}
NavDestination.WATCH_LATER -> {
LibraryScreen(
title = "Watch Later",
@@ -142,6 +158,7 @@ class MainActivity : ComponentActivity() {
onToggleWatchLater = { libraryManager.toggle("wl", it) },
onToggleFavorite = { libraryManager.toggle("fav", it) },
onPlay = { itm, ep ->
libraryManager.addHistory(itm)
playingItem = itm
playingEpisode = ep
},

View File

@@ -1,71 +0,0 @@
package tv.vyla.app.api
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import tv.vyla.app.model.MediaSegment
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
object IntroDbApi {
private val client = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(8, TimeUnit.SECONDS)
.build()
private val labels = mapOf(
"recap" to "Skip Recap",
"intro" to "Skip Intro",
"preview" to "Skip Preview",
"credits" to "Skip Credits"
)
suspend fun getTimestamps(
tmdbId: Int,
season: Int? = null,
episode: Int? = null,
durationSec: Double? = null
): List<MediaSegment> = withContext(Dispatchers.IO) {
val sb = StringBuilder("https://api.theintrodb.org/v3/media?tmdb_id=$tmdbId")
if (season != null) sb.append("&season=$season")
if (episode != null) sb.append("&episode=$episode")
if (durationSec != null && durationSec > 0) {
sb.append("&duration_ms=").append((durationSec * 1000).roundToInt())
}
val req = Request.Builder().url(sb.toString()).build()
try {
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) return@withContext emptyList()
val body = resp.body?.string() ?: return@withContext emptyList()
val json = JSONObject(body)
val segments = mutableListOf<MediaSegment>()
labels.forEach { (type, label) ->
val arr = json.optJSONArray(type)
if (arr != null) {
for (i in 0 until arr.length()) {
val seg = arr.getJSONObject(i)
val startMs = seg.optDouble("start_ms", 0.0)
val endMs = if (seg.has("end_ms") && !seg.isNull("end_ms")) seg.optDouble("end_ms") else null
segments.add(
MediaSegment(
type = type,
label = label,
start = startMs / 1000.0,
end = endMs?.let { it / 1000.0 }
)
)
}
}
}
segments
}
} catch (_: IOException) {
emptyList()
}
}
}

View File

@@ -0,0 +1,84 @@
package tv.vyla.app.api
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
data class SkipSegment(
val type: String,
val start: Double,
val end: Double?,
val label: String
)
object IntroDbV3 {
private const val BASE_URL = "https://api.theintrodb.org/v3/media"
suspend fun getSegments(
tmdbId: Number,
season: Int?,
episode: Int?,
durationMs: Long? = null
): List<SkipSegment> = withContext(Dispatchers.IO) {
try {
val url = StringBuilder("$BASE_URL?tmdb_id=$tmdbId")
if (season != null && episode != null) {
url.append("&season=$season&episode=$episode")
}
if (durationMs != null && durationMs > 0L) {
url.append("&duration_ms=$durationMs")
}
val conn = URL(url.toString()).openConnection() as HttpURLConnection
conn.connectTimeout = 8000
conn.readTimeout = 8000
conn.setRequestProperty("Accept", "application/json")
try {
if (conn.responseCode != 200) {
emptyList()
} else {
val body = conn.inputStream.bufferedReader().use { it.readText() }
parse(JSONObject(body))
}
} finally {
conn.disconnect()
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
emptyList()
}
}
private fun parse(json: JSONObject): List<SkipSegment> {
val result = ArrayList<SkipSegment>()
val kinds = listOf(
Triple("intro", "Skip Intro", true),
Triple("recap", "Skip Recap", true),
Triple("credits", "Skip Credits", false),
Triple("preview", "Skip Preview", false)
)
for ((key, label, startOptional) in kinds) {
val array = json.optJSONArray(key) ?: continue
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val startMs = if (obj.isNull("start_ms")) null else obj.optLong("start_ms")
val endMs = if (obj.isNull("end_ms")) null else obj.optLong("end_ms")
if (startOptional && endMs == null) continue
val resolvedStart = startMs ?: (if (startOptional) 0L else null) ?: continue
if (endMs != null && endMs <= resolvedStart) continue
result.add(
SkipSegment(
type = key,
start = resolvedStart / 1000.0,
end = endMs?.let { it / 1000.0 },
label = label
)
)
}
}
return result.sortedBy { it.start }
}
}

View File

@@ -52,6 +52,12 @@ object TmdbApi {
parseMediaList(json.optJSONArray("results"), mediaType)
}
suspend fun getAnimeDiscover(genreId: Int, page: Int): List<MediaItem> = withContext(Dispatchers.IO) {
val path = "/discover/tv?with_genres=$genreId&with_original_language=ja&sort_by=popularity.desc&vote_count.gte=50&include_adult=false&page=$page"
val json = fetchJson(path) ?: return@withContext emptyList()
parseMediaList(json.optJSONArray("results"), "tv")
}
suspend fun getTrending(mediaType: String): List<MediaItem> = withContext(Dispatchers.IO) {
val json = fetchJson("/trending/$mediaType/week") ?: return@withContext emptyList()
parseMediaList(json.optJSONArray("results"), mediaType)
@@ -302,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()
@@ -310,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

@@ -30,10 +30,21 @@ class LibraryManager private constructor(context: Context) {
private val _favorites = MutableStateFlow<List<MediaItem>>(emptyList())
val favorites: StateFlow<List<MediaItem>> = _favorites.asStateFlow()
private val _history = MutableStateFlow<List<MediaItem>>(emptyList())
val history: StateFlow<List<MediaItem>> = _history.asStateFlow()
init {
load()
}
fun addHistory(item: MediaItem) {
val current = _history.value.toMutableList()
current.removeAll { it.id == item.id && it.mediaType == item.mediaType }
current.add(0, item)
_history.value = current.take(100)
save()
}
private fun load() {
val raw = prefs.getString("vyla.lib", null)
if (raw.isNullOrEmpty()) return
@@ -41,6 +52,7 @@ class LibraryManager private constructor(context: Context) {
val root = JSONObject(raw)
_watchLater.value = parseList(root.optJSONArray("wl"))
_favorites.value = parseList(root.optJSONArray("fav"))
_history.value = parseList(root.optJSONArray("hist"))
} catch (e: Exception) {
e.printStackTrace()
}
@@ -51,6 +63,7 @@ class LibraryManager private constructor(context: Context) {
val root = JSONObject()
root.put("wl", serializeList(_watchLater.value))
root.put("fav", serializeList(_favorites.value))
root.put("hist", serializeList(_history.value))
prefs.edit().putString("vyla.lib", root.toString()).apply()
} catch (e: Exception) {
e.printStackTrace()

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

@@ -0,0 +1,48 @@
package tv.vyla.app.ui.components
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.unit.dp
object SearchIcons {
val Space: ImageVector by lazy {
build("Space", "M4 10v6h16v-6")
}
val Search: ImageVector by lazy {
build("Search", "M11 4a7 7 0 1 0 0 14a7 7 0 0 0 0-14z", "M16 16l5 5")
}
val Backspace: ImageVector by lazy {
build(
"Backspace",
"M21 5H8l-6 7 6 7h13a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1z",
"M12 9.5l5 5",
"M17 9.5l-5 5"
)
}
private fun build(name: String, vararg paths: String): ImageVector {
val builder = ImageVector.Builder(
name = name,
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
)
paths.forEach { d ->
builder.addPath(
pathData = addPathNodes(d),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round
)
}
return builder.build()
}
}

View File

@@ -36,19 +36,31 @@ import androidx.compose.ui.zIndex
import tv.vyla.app.R
import tv.vyla.app.ui.theme.VylaTextMuted
import tv.vyla.app.ui.theme.VylaTextPrimary
import kotlinx.coroutines.delay
import androidx.compose.foundation.focusGroup
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.focus.focusProperties
object ContentFocusBridge {
var restore: (() -> Boolean)? = null
}
enum class NavDestination(val key: String, val title: String, val icon: ImageVector) {
SEARCH("search", "Search", Icons.Default.Search),
DISCOVERY("discover", "Discovery", Icons.Default.Explore),
MOVIES("movie", "Movies", Icons.Default.Movie),
TV_SHOWS("tv", "TV Shows", Icons.Default.Tv),
ANIME("anime", "Anime", Icons.Default.AutoAwesome),
HISTORY("history", "History", Icons.Default.History),
WATCH_LATER("wl", "Watch Later", Icons.Default.Schedule),
FAVORITES("fav", "Favorites", Icons.Default.Favorite)
}
@Composable
@OptIn(ExperimentalComposeUiApi::class)
fun Sidebar(
currentDestination: NavDestination,
hasHistory: Boolean,
hasWatchLater: Boolean,
hasFavorites: Boolean,
onNavigate: (NavDestination) -> Unit,
@@ -57,102 +69,113 @@ fun Sidebar(
) {
var focusedDestination by remember { mutableStateOf<NavDestination?>(null) }
val isSidebarFocused = focusedDestination != null
val destinationFocusRequesters = remember { mutableMapOf<NavDestination, FocusRequester>() }
val animatedWidth by animateDpAsState(
targetValue = if (isSidebarFocused) 260.dp else 68.dp,
animationSpec = tween(durationMillis = 280),
targetValue = if (isSidebarFocused) 220.dp else 58.dp,
animationSpec = tween(durationMillis = 220),
label = "sidebar_width"
)
LaunchedEffect(Unit) {
delay(60)
try {
destinationFocusRequesters[currentDestination]?.requestFocus()
} catch (_: Exception) {}
}
Box(
modifier = modifier
.fillMaxHeight()
.width(animatedWidth)
.zIndex(10f)
.focusProperties {
enter = { destinationFocusRequesters[currentDestination] ?: FocusRequester.Default }
}
.focusGroup()
.background(
Brush.horizontalGradient(
colors = if (isSidebarFocused) {
listOf(Color(0xF0000000), Color(0xB3000000), Color.Transparent)
listOf(Color(0xF207070A), Color(0xCC07070A), Color.Transparent)
} else {
listOf(Color(0xCC000000), Color.Transparent)
listOf(Color(0xD907070A), Color.Transparent)
}
)
)
.padding(vertical = 24.dp)
.padding(vertical = 20.dp)
) {
Column(
modifier = Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = 14.dp, top = 2.dp)
.height(36.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.height(44.dp)
.size(30.dp)
.clip(CircleShape)
.background(Color.White),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.White),
contentAlignment = Alignment.Center
) {
androidx.compose.foundation.Image(
painter = painterResource(R.drawable.icon),
contentDescription = "Vyla Logo",
modifier = Modifier.size(32.dp),
contentScale = ContentScale.Fit
)
androidx.compose.foundation.Image(
painter = painterResource(R.drawable.icon),
contentDescription = "Vyla Logo",
modifier = Modifier.size(24.dp),
contentScale = ContentScale.Fit
)
}
AnimatedVisibility(
visible = isSidebarFocused,
enter = fadeIn(tween(160)),
exit = fadeOut(tween(100))
) {
Text(
text = "Vyla",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.5).sp,
modifier = Modifier.padding(start = 12.dp)
)
}
}
Column(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
NavDestination.values().forEach { destination ->
val shouldShow = when (destination) {
NavDestination.HISTORY -> hasHistory || currentDestination == NavDestination.HISTORY
NavDestination.WATCH_LATER -> hasWatchLater || currentDestination == NavDestination.WATCH_LATER
NavDestination.FAVORITES -> hasFavorites || currentDestination == NavDestination.FAVORITES
else -> true
}
AnimatedVisibility(
visible = isSidebarFocused,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(150))
) {
Text(
text = "Vyla",
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.5).sp,
modifier = Modifier.padding(start = 14.dp)
if (shouldShow) {
val requester = destinationFocusRequesters.getOrPut(destination) { FocusRequester() }
SidebarItem(
destination = destination,
isSelected = currentDestination == destination,
isExpanded = isSidebarFocused,
focusRequester = requester,
onFocusGained = { focusedDestination = destination },
onFocusLost = { if (focusedDestination == destination) focusedDestination = null },
onSelect = {
focusedDestination = null
onNavigate(destination)
},
onRightPress = {
focusedDestination = null
if (ContentFocusBridge.restore?.invoke() != true) onRequestExitSidebar()
}
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 10.dp)
) {
NavDestination.values().forEach { destination ->
val shouldShow = when (destination) {
NavDestination.WATCH_LATER -> hasWatchLater || currentDestination == NavDestination.WATCH_LATER
NavDestination.FAVORITES -> hasFavorites || currentDestination == NavDestination.FAVORITES
else -> true
}
if (shouldShow) {
SidebarItem(
destination = destination,
isSelected = currentDestination == destination,
isExpanded = isSidebarFocused,
onFocusGained = { focusedDestination = destination },
onFocusLost = { if (focusedDestination == destination) focusedDestination = null },
onSelect = {
focusedDestination = null
onNavigate(destination)
},
onRightPress = {
focusedDestination = null
onRequestExitSidebar()
}
)
}
}
}
Spacer(modifier = Modifier.height(40.dp))
}
}
}
@@ -162,6 +185,7 @@ private fun SidebarItem(
destination: NavDestination,
isSelected: Boolean,
isExpanded: Boolean,
focusRequester: FocusRequester? = null,
onFocusGained: () -> Unit,
onFocusLost: () -> Unit,
onSelect: () -> Unit,
@@ -169,38 +193,37 @@ private fun SidebarItem(
) {
var isItemFocused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(26.dp)
val background = when {
isItemFocused -> Color.White
isSelected && !isExpanded -> Color(0x33FFFFFF)
else -> Color.Transparent
}
val contentColor = when {
isItemFocused -> Color.Black
isSelected -> Color.White
else -> VylaTextMuted
else -> Color(0x99FFFFFF)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.clip(shape)
.height(42.dp)
.clip(RoundedCornerShape(21.dp))
.background(background)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { state ->
isItemFocused = state.isFocused
if (state.isFocused) onFocusGained() else onFocusLost()
}
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown) {
if (event.key == Key.DirectionRight) {
onRightPress()
return@onKeyEvent true
}
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight) {
onRightPress()
true
} else {
false
}
false
}
.focusable()
.clickable(
interactionSource = remember { MutableInteractionSource() },
@@ -208,28 +231,32 @@ private fun SidebarItem(
) {
onSelect()
}
.padding(horizontal = 12.dp)
) {
Icon(
imageVector = destination.icon,
contentDescription = destination.title,
tint = contentColor,
modifier = Modifier.size(24.dp)
)
Box(
modifier = Modifier.size(42.dp),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = destination.icon,
contentDescription = destination.title,
tint = contentColor,
modifier = Modifier.size(22.dp)
)
}
AnimatedVisibility(
visible = isExpanded,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(150))
enter = fadeIn(tween(160)),
exit = fadeOut(tween(100))
) {
Text(
text = destination.title,
color = contentColor,
fontSize = 17.sp,
fontSize = 15.sp,
fontWeight = if (isSelected || isItemFocused) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
modifier = Modifier.padding(start = 16.dp)
modifier = Modifier.padding(start = 2.dp, end = 14.dp)
)
}
}
}
}

View File

@@ -0,0 +1,187 @@
package tv.vyla.app.ui.components
import android.graphics.Bitmap
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.PixelCopy
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalView
import kotlinx.coroutines.delay
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.math.roundToInt
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.BitmapDrawable
import androidx.compose.ui.platform.LocalContext
import coil.imageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
private const val GRID_W = 32
private const val GRID_H = 18
class VideoFrameColors(
private val pixels: IntArray,
private val width: Int,
private val height: Int
) {
fun colorAt(fx: Float, fy: Float): Color {
val cx = (fx.coerceIn(0f, 1f) * (width - 1)).roundToInt()
val cy = (fy.coerceIn(0f, 1f) * (height - 1)).roundToInt()
var r = 0
var g = 0
var b = 0
var n = 0
for (dy in -1..1) {
for (dx in -1..1) {
val x = (cx + dx).coerceIn(0, width - 1)
val y = (cy + dy).coerceIn(0, height - 1)
val p = pixels[y * width + x]
r += (p shr 16) and 0xFF
g += (p shr 8) and 0xFF
b += p and 0xFF
n++
}
}
return Color(r / n, g / n, b / n)
}
}
object VideoTintState {
var frame: VideoFrameColors? by mutableStateOf(null)
}
@Composable
fun VideoTintSampler(active: Boolean, viewProvider: () -> View?) {
val provider by rememberUpdatedState(viewProvider)
DisposableEffect(Unit) {
onDispose { VideoTintState.frame = null }
}
LaunchedEffect(active) {
if (!active) return@LaunchedEffect
val bitmap = Bitmap.createBitmap(GRID_W, GRID_H, Bitmap.Config.ARGB_8888)
val pixels = IntArray(GRID_W * GRID_H)
while (true) {
val view = provider()
if (view != null && captureFrame(view, bitmap)) {
bitmap.getPixels(pixels, 0, GRID_W, 0, 0, GRID_W, GRID_H)
VideoTintState.frame = VideoFrameColors(pixels.copyOf(), GRID_W, GRID_H)
}
delay(700)
}
}
}
private suspend fun captureFrame(view: View, bitmap: Bitmap): Boolean {
return when (view) {
is SurfaceView -> {
if (Build.VERSION.SDK_INT < 24 || !view.holder.surface.isValid || view.width == 0 || view.height == 0) {
false
} else {
suspendCancellableCoroutine { cont ->
try {
PixelCopy.request(
view,
bitmap,
{ result -> if (cont.isActive) cont.resume(result == PixelCopy.SUCCESS) },
Handler(Looper.getMainLooper())
)
} catch (_: Exception) {
if (cont.isActive) cont.resume(false)
}
}
}
}
is TextureView -> view.isAvailable && view.getBitmap(bitmap) != null
else -> false
}
}
private fun tinted(sample: Color, selected: Boolean): Color {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(sample.toArgb(), hsv)
hsv[1] = (hsv[1] * 1.6f).coerceAtMost(1f)
hsv[2] = if (selected) {
(hsv[2] * 0.9f).coerceIn(0.3f, 0.85f)
} else {
(hsv[2] * 0.5f).coerceIn(0.05f, 0.45f)
}
return Color(android.graphics.Color.HSVToColor(hsv)).copy(alpha = if (selected) 0.9f else 0.8f)
}
@Composable
fun Modifier.videoTint(isFocused: Boolean, isSelected: Boolean = false): Modifier {
val frame = VideoTintState.frame
val rootView = LocalView.current.rootView
var center by remember { mutableStateOf(Offset.Unspecified) }
val target = when {
isFocused -> Color(0xF2FFFFFF)
frame == null || center == Offset.Unspecified || rootView.width == 0 || rootView.height == 0 ->
if (isSelected) Color(0xCC1A1A1A) else Color(0x99020202)
else -> tinted(
frame.colorAt(center.x / rootView.width, center.y / rootView.height),
isSelected
)
}
val animated by animateColorAsState(target, tween(350), label = "video_tint")
return this
.onGloballyPositioned { center = it.boundsInWindow().center }
.drawBehind { drawRect(animated) }
}
@Composable
fun BackdropTintSampler(url: String?) {
val context = LocalContext.current
DisposableEffect(Unit) {
onDispose { VideoTintState.frame = null }
}
LaunchedEffect(url) {
if (url.isNullOrEmpty()) {
VideoTintState.frame = null
return@LaunchedEffect
}
val request = ImageRequest.Builder(context)
.data(url)
.size(48, 27)
.allowHardware(false)
.build()
val result = context.imageLoader.execute(request)
val source = ((result as? SuccessResult)?.drawable as? BitmapDrawable)?.bitmap
?: return@LaunchedEffect
val out = Bitmap.createBitmap(GRID_W, GRID_H, Bitmap.Config.ARGB_8888)
val targetRatio = GRID_W.toFloat() / GRID_H
val sourceRatio = source.width.toFloat() / source.height
val srcRect = if (sourceRatio > targetRatio) {
val w = (source.height * targetRatio).toInt()
Rect((source.width - w) / 2, 0, (source.width + w) / 2, source.height)
} else {
val h = (source.width / targetRatio).toInt()
Rect(0, (source.height - h) / 2, source.width, (source.height + h) / 2)
}
Canvas(out).drawBitmap(source, srcRect, Rect(0, 0, GRID_W, GRID_H), null)
val pixels = IntArray(GRID_W * GRID_H)
out.getPixels(pixels, 0, GRID_W, 0, 0, GRID_W, GRID_H)
VideoTintState.frame = VideoFrameColors(pixels, GRID_W, GRID_H)
}
}

View File

@@ -1,106 +1,66 @@
package tv.vyla.app.ui.screens
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
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.graphicsLayer
import androidx.compose.ui.input.key.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import tv.vyla.app.api.TmdbApi
import tv.vyla.app.model.Genre
import tv.vyla.app.model.MediaItem
import tv.vyla.app.ui.components.MediaCard
import tv.vyla.app.ui.components.NavDestination
import tv.vyla.app.ui.theme.*
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.focus.focusRestorer
import kotlinx.coroutines.flow.first
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(
@@ -112,8 +72,9 @@ data class GenreRow(
val items: List<MediaItem>
)
@Composable
@Composable
@OptIn(ExperimentalComposeUiApi::class)
fun BrowseScreen(
mode: NavDestination,
onOpenDetail: (MediaItem) -> Unit,
@@ -131,37 +92,73 @@ fun BrowseScreen(
val movieGenres = remember { mutableStateListOf<Genre>() }
val tvGenres = remember { mutableStateListOf<Genre>() }
val playButtonFocusRequester = remember { FocusRequester() }
val currentMode by rememberUpdatedState(mode)
var loadGeneration by remember { mutableIntStateOf(0) }
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)
}
}
suspend fun loadMoreRows(count: Int) {
if (isLoadingMore) return
isLoadingMore = true
val isMovieMode = mode == NavDestination.MOVIES
val isTvMode = mode == NavDestination.TV_SHOWS
val isDiscover = mode == NavDestination.DISCOVERY
for (i in 0 until count) {
fun pinAmbientToHero() {
heroItem?.let { ambientUrl = it.ambientBackdropUrl }
}
suspend fun loadMoreRows(count: Int) {
if (isLoadingMore || rows.size >= 48) return
isLoadingMore = true
val generation = loadGeneration
val activeMode = currentMode
try {
val isMovieMode = activeMode == NavDestination.MOVIES
val isTvMode = activeMode == NavDestination.TV_SHOWS
val isAnimeMode = activeMode == NavDestination.ANIME
val isDiscover = activeMode == NavDestination.DISCOVERY
var added = 0
var attempts = 0
while (added < count && attempts < count * 4) {
attempts++
val curI = rowIdx++
val isMovie = isMovieMode || (isDiscover && curI % 2 == 0)
val isMovie = when {
isMovieMode -> true
isTvMode -> false
isAnimeMode -> false
isDiscover -> curI % 2 == 0
else -> true
}
val list = if (isMovie) movieGenres else tvGenres
if (list.isEmpty()) continue
val genreIdx = if (isDiscover) curI / 2 else curI
val genre = list[genreIdx % list.size]
val page = 1 + (genreIdx / list.size) % 15
val mt = if (isMovie) "movie" else "tv"
val items = TmdbApi.getDiscover(mt, genre.id, page)
val items = if (isAnimeMode) {
TmdbApi.getAnimeDiscover(genre.id, page)
} else {
TmdbApi.getDiscover(mt, genre.id, page)
}
if (generation != loadGeneration) return
if (items.isNotEmpty()) {
val title = "${genre.name} ${if (isMovie) "Movies" else "TV Shows"}"
val title = when {
isAnimeMode -> "${genre.name} Anime"
else -> "${genre.name} ${if (isMovie) "Movies" else "TV Shows"}"
}
rows.add(GenreRow(title, mt, genre.id, items))
if (heroItem == null && rows.isNotEmpty()) {
added++
if (heroItem == null) {
val first = rows[0].items.firstOrNull()
if (first != null) {
heroItem = first
ambientUrl = first.ambientBackdropUrl
coroutineScope.launch {
heroLogoUrl = TmdbApi.getLogo(first.mediaType, first.id)
}
@@ -169,37 +166,74 @@ fun BrowseScreen(
}
}
}
isLoadingMore = false
} finally {
if (generation == loadGeneration) isLoadingMore = false
}
LaunchedEffect(mode) {
rows.clear()
rowIdx = 0
heroItem = null
heroLogoUrl = null
movieGenres.clear()
tvGenres.clear()
movieGenres.addAll(TmdbApi.getGenreList("movie"))
tvGenres.addAll(TmdbApi.getGenreList("tv"))
loadMoreRows(5)
}
fun restoreContentFocus(): Boolean {
val cardRequester = lastFocusedKey?.let { cardRequesters[it] }
if (cardRequester != null) {
try {
cardRequester.requestFocus()
return true
} catch (_: Exception) {}
}
val listState = rememberLazyListState()
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)
)
}
return try {
playButtonFocusRequester.requestFocus()
true
} catch (_: Exception) {
false
}
}
DisposableEffect(Unit) {
val restore: () -> Boolean = { restoreContentFocus() }
ContentFocusBridge.restore = restore
onDispose {
if (ContentFocusBridge.restore === restore) ContentFocusBridge.restore = null
}
}
LaunchedEffect(mode) {
loadGeneration++
isLoadingMore = false
lastFocusedKey = null
cardRequesters.clear()
rows.clear()
rowIdx = 0
heroItem = null
ambientUrl = null
lastRowIndex = -1
heroLogoUrl = null
movieGenres.clear()
tvGenres.clear()
listState.scrollToItem(0)
movieGenres.addAll(TmdbApi.getGenreList("movie"))
tvGenres.addAll(TmdbApi.getGenreList("tv"))
loadMoreRows(5)
withTimeoutOrNull(10000) { snapshotFlow { heroItem }.first { it != null } }
delay(150)
var tries = 0
var focused = false
while (!focused && tries < 10 && lastFocusedKey == null) {
tries++
try {
playButtonFocusRequester.requestFocus()
focused = true
} catch (_: Exception) {
delay(100)
}
}
}
BackdropTintSampler(heroItem?.ambientBackdropUrl)
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
AmbientBackdrop(
url = ambientUrl,
modifier = Modifier.fillMaxSize(),
durationMs = 700
)
Box(
modifier = Modifier
.fillMaxSize()
@@ -222,6 +256,9 @@ fun BrowseScreen(
logoUrl = heroLogoUrl,
playButtonFocusRequester = playButtonFocusRequester,
onFocusedTop = {
lastFocusedKey = null
lastRowIndex = -1
pinAmbientToHero()
coroutineScope.launch {
listState.animateScrollToItem(0)
}
@@ -236,7 +273,8 @@ fun BrowseScreen(
}
)
}
items(rows) { row ->
itemsIndexed(rows, key = { i, row -> "$i:${row.title}" }) { rowIndex, row ->
val rowState = rememberLazyListState()
Column(modifier = Modifier.padding(vertical = 10.dp)) {
Text(
text = row.title,
@@ -246,17 +284,40 @@ fun BrowseScreen(
modifier = Modifier.padding(start = 78.dp, bottom = 12.dp)
)
LazyRow(
state = rowState,
contentPadding = PaddingValues(horizontal = 78.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(row.items) { item ->
val isFirstInRow = row.items.firstOrNull() == item
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,
width = 136,
onSelect = { onOpenDetail(item) },
onFocused = { updateHero(item) },
modifier = Modifier.onKeyEvent { event ->
onFocused = {
lastFocusedKey = cardKey
updateHero(item)
coroutineScope.launch {
val viewportWidth = rowState.layoutInfo.viewportEndOffset - rowState.layoutInfo.viewportStartOffset
rowState.animateScrollToItem(
index,
-(viewportWidth / 2 - 68)
)
}
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 ->
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionLeft && isFirstInRow) {
onRequestSidebar()
return@onKeyEvent true
@@ -292,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)
@@ -440,30 +494,24 @@ private fun HeroPillButton(
targetValue = if (isFocused) 1.05f else 1.0f,
label = "pill_scale"
)
val background = when {
isPrimary && isFocused -> Color.White
isPrimary -> Color.White
isFocused -> Color(0x55FFFFFF)
else -> VylaPillBackground
}
val primaryBackground = if (isFocused) Color.White else Color(0xD9FFFFFF)
val contentColor = when {
isPrimary -> Color.Black
isFocused -> Color.Black
else -> Color.White
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.scale(scale)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.height(48.dp)
.clip(RoundedCornerShape(24.dp))
.background(background)
.then(
if (isFocused && isPrimary) {
Modifier.border(2.dp, Color(0xFFCCCCCC), RoundedCornerShape(24.dp))
} else if (isFocused) {
Modifier.border(2.dp, Color.White, RoundedCornerShape(24.dp))
} else Modifier
if (isPrimary) Modifier.background(primaryBackground) else Modifier.videoTint(isFocused)
)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged {

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(
@@ -132,14 +138,15 @@ fun DetailScreen(
var isMenuOpen by remember { mutableStateOf(false) }
var isSeasonDropdownOpen by remember { mutableStateOf(false) }
val playButtonFocusRequester = remember { FocusRequester() }
val firstEpisodeFocusRequester = remember { FocusRequester() }
var shouldFocusFirstEpisodeAfterSeasonChange by remember { mutableStateOf(false) }
LaunchedEffect(item) {
listState.scrollToItem(0)
val d = TmdbApi.getDetail(item.mediaType, item.id)
detail = d
if (d != null && d.seasons.isNotEmpty()) {
val firstSeason = d.seasons.first()
selectedSeason = firstSeason
seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, firstSeason.seasonNumber)
val initialSeason = d.seasons.firstOrNull { it.seasonNumber == 1 } ?: d.seasons.first()
selectedSeason = initialSeason
}
delay(150)
try {
@@ -150,6 +157,13 @@ fun DetailScreen(
val s = selectedSeason
if (s != null) {
seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, s.seasonNumber)
if (shouldFocusFirstEpisodeAfterSeasonChange) {
shouldFocusFirstEpisodeAfterSeasonChange = false
delay(120)
try {
firstEpisodeFocusRequester.requestFocus()
} catch (_: Exception) {}
}
}
}
BackHandler {
@@ -181,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()
)
}
}
@@ -387,6 +382,26 @@ fun DetailScreen(
}
if (hasEpisodes) {
item {
val seasonsList = d?.seasons ?: emptyList()
val seasonChipFocusRequesters = remember(d) {
mutableMapOf<Int, FocusRequester>()
}
val seasonRowState = rememberLazyListState()
LaunchedEffect(isSeasonDropdownOpen) {
if (isSeasonDropdownOpen) {
val targetIndex = seasonsList.indexOfFirst { it.seasonNumber == selectedSeason?.seasonNumber }
if (targetIndex >= 0) {
seasonRowState.scrollToItem(targetIndex)
delay(60)
try {
val targetSeason = seasonsList[targetIndex]
seasonChipFocusRequesters[targetSeason.seasonNumber]?.requestFocus()
} catch (_: Exception) {}
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -414,19 +429,22 @@ fun DetailScreen(
}
if (isSeasonDropdownOpen) {
LazyRow(
state = seasonRowState,
contentPadding = PaddingValues(horizontal = 48.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.padding(vertical = 10.dp)
.background(Color(0x9916161E))
.padding(vertical = 8.dp)
modifier = Modifier.padding(vertical = 18.dp)
) {
items(d?.seasons ?: emptyList()) { season ->
items(seasonsList) { season ->
val requester = seasonChipFocusRequesters.getOrPut(season.seasonNumber) {
FocusRequester()
}
SeasonSelectChip(
season = season,
isSelected = selectedSeason?.seasonNumber == season.seasonNumber,
focusRequester = requester,
onFocused = { scrollToSection(episodesItemIndex) },
onSelect = {
shouldFocusFirstEpisodeAfterSeasonChange = true
selectedSeason = season
isSeasonDropdownOpen = false
}
@@ -449,15 +467,17 @@ fun DetailScreen(
modifier = Modifier.padding(vertical = 16.dp)
)
} else {
rowsOfEpisodes.forEach { rowEpisodes ->
rowsOfEpisodes.forEachIndexed { rowIndex, rowEpisodes ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(18.dp)
) {
rowEpisodes.forEach { ep ->
rowEpisodes.forEachIndexed { colIndex, ep ->
val isFirstEpisode = rowIndex == 0 && colIndex == 0
Box(modifier = Modifier.weight(1f)) {
DetailEpisodeCard(
episode = ep,
focusRequester = if (isFirstEpisode) firstEpisodeFocusRequester else null,
onFocused = { scrollToSection(episodesItemIndex) },
onClick = { onPlay(item, ep) }
)
@@ -770,10 +790,12 @@ private fun SeasonDropdownButton(
)
}
}
@Composable
private fun SeasonSelectChip(
season: Season,
isSelected: Boolean,
focusRequester: FocusRequester? = null,
onFocused: (() -> Unit)? = null,
onSelect: () -> Unit
) {
@@ -782,15 +804,15 @@ private fun SeasonSelectChip(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.clip(RoundedCornerShape(12.dp))
.background(
when {
isFocused -> Color.White
isSelected -> Color(0x44FFFFFF)
else -> Color(0x18FFFFFF)
isFocused -> Color(0xF2FFFFFF)
isSelected -> Color(0x66FFFFFF)
else -> Color(0x99020202)
}
)
.then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(12.dp)) else Modifier)
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) onFocused?.invoke()
@@ -816,9 +838,11 @@ private fun SeasonSelectChip(
)
}
}
@Composable
private fun DetailEpisodeCard(
episode: Episode,
focusRequester: FocusRequester? = null,
onFocused: (() -> Unit)? = null,
onClick: () -> Unit
) {
@@ -828,6 +852,7 @@ private fun DetailEpisodeCard(
modifier = Modifier
.fillMaxWidth()
.scale(scale)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) onFocused?.invoke()
@@ -906,21 +931,34 @@ private fun DetailEpisodeCard(
)
}
}
@Composable
private fun DetailTrailerCard(
trailer: VideoClip,
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")
Column(
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()
@@ -930,53 +968,102 @@ private fun DetailTrailerCard(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onClick() }
.padding(bottom = 14.dp)
) {
AsyncImage(
model = tinyRequest,
contentDescription = null,
contentScale = ContentScale.Crop,
filterQuality = FilterQuality.Medium,
modifier = Modifier.matchParentSize()
)
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
) {
AsyncImage(
model = trailer.thumbnailUrl,
contentDescription = trailer.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
.matchParentSize()
.background(
Brush.verticalGradient(
0.0f to Color(0x26000000),
1.0f to Color(0x80000000)
)
)
)
Column {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color(0x88000000))
.align(Alignment.Center),
contentAlignment = Alignment.Center
.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
)
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(26.dp)
AsyncImage(
model = trailer.thumbnailUrl,
contentDescription = trailer.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(Color(0x88000000))
.align(Alignment.Center),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(26.dp)
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 14.dp, end = 14.dp, bottom = 14.dp)
) {
Text(
text = trailer.type.uppercase(),
color = Color(0xB3FFFFFF),
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.2.sp
)
Text(
text = trailer.name,
color = Color.White,
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp)
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = Color(0xCCFFFFFF),
modifier = Modifier.size(14.dp)
)
Text(
text = trailer.site,
color = Color(0xCCFFFFFF),
fontSize = 12.sp,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}
Text(
text = trailer.name,
color = Color(0xFFF2F2F5),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, top = 10.dp)
)
Text(
text = "▶ ${trailer.type}",
color = Color(0xFFD0D0DA),
fontSize = 12.sp,
modifier = Modifier.padding(start = 14.dp, end = 14.dp, top = 4.dp)
)
}
}
@Composable
private fun DetailCastPortrait(
member: CastMember,

View File

@@ -17,18 +17,47 @@ import tv.vyla.app.ui.components.MediaCard
import tv.vyla.app.ui.theme.VylaBackground
import tv.vyla.app.ui.theme.VylaTextMuted
import tv.vyla.app.ui.theme.VylaTextPrimary
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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(
title: String,
items: List<MediaItem>,
onOpenDetail: (MediaItem) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
emptyMessage: String = "Nothing here yet. Open a title, press the ⋮ button and add it."
) {
Column(
modifier = modifier
var ambientUrl by remember(items) { mutableStateOf(items.firstOrNull()?.ambientBackdropUrl) }
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
AmbientBackdrop(
url = ambientUrl,
modifier = Modifier.fillMaxSize(),
durationMs = 700
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0.0f to Color(0x0D000000),
0.55f to Color(0x8C000000),
1.0f to Color(0xF207070A)
)
)
)
Column(
modifier = Modifier
.fillMaxSize()
.background(VylaBackground)
.padding(start = 78.dp, end = 48.dp, top = 36.dp)
) {
Row(
@@ -58,7 +87,7 @@ fun LibraryScreen(
contentAlignment = androidx.compose.ui.Alignment.TopStart
) {
Text(
text = "Nothing here yet. Open a title, press the ⋮ button and add it.",
text = emptyMessage,
color = VylaTextMuted,
fontSize = 17.sp
)
@@ -75,10 +104,12 @@ fun LibraryScreen(
MediaCard(
item = item,
width = 140,
onSelect = { onOpenDetail(item) }
onSelect = { onOpenDetail(item) },
onFocused = { ambientUrl = it.ambientBackdropUrl }
)
}
}
}
}
}
}
}

View File

@@ -1,150 +1,84 @@
package tv.vyla.app.ui.screens
import android.content.Context
import android.media.AudioManager
import android.view.KeyEvent
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeMute
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.media3.common.C
import androidx.media3.common.MediaItem as ExoMediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import tv.vyla.app.api.IntroDbApi
import tv.vyla.app.api.IntroDbV3
import tv.vyla.app.api.SkipSegment
import tv.vyla.app.api.TmdbApi
import tv.vyla.app.data.LibraryManager
import tv.vyla.app.model.*
import tv.vyla.app.proxy.LocalProxy
import tv.vyla.app.source.VylaResolver
import tv.vyla.app.ui.theme.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import tv.vyla.app.ui.components.VideoTintSampler
import tv.vyla.app.ui.components.videoTint
enum class ActivePlayerMenu {
NONE, QUALITY, SUBTITLES, VOLUME, SETTINGS, MORE
@@ -175,8 +109,11 @@ fun PlayerScreen(
var currentPositionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
var bufferedPositionMs by remember { mutableLongStateOf(0L) }
val segments = remember { mutableStateListOf<MediaSegment>() }
var activeSegment by remember { mutableStateOf<MediaSegment?>(null) }
val segments = remember { mutableStateListOf<SkipSegment>() }
var activeSegment by remember { mutableStateOf<SkipSegment?>(null) }
val skipButtonFocusRequester = remember { FocusRequester() }
val nextButtonFocusRequester = remember { FocusRequester() }
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
var areControlsVisible by remember { mutableStateOf(true) }
var activeMenu by remember { mutableStateOf(ActivePlayerMenu.NONE) }
var isEpisodesDrawerOpen by remember { mutableStateOf(false) }
@@ -190,6 +127,9 @@ fun PlayerScreen(
val seekbarFocusRequester = remember { FocusRequester() }
val menuFirstItemFocusRequester = remember { FocusRequester() }
val episodesButtonFocusRequester = remember { FocusRequester() }
val episodeFocusRequester = remember { FocusRequester() }
val episodesListState = rememberLazyListState()
var episodeFocusIndex by remember { mutableIntStateOf(0) }
val qualityButtonFocusRequester = remember { FocusRequester() }
val ccButtonFocusRequester = remember { FocusRequester() }
val volButtonFocusRequester = remember { FocusRequester() }
@@ -238,18 +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
val seg = segments.firstOrNull { s ->
val afterStart = currentSec >= s.start
val beforeEnd = s.end == null || currentSec <= s.end
afterStart && beforeEnd
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
}
activeSegment = seg
delay(300)
val currentSec = pos / 1000.0
val seg = segments.firstOrNull { s ->
val endSec = s.end
currentSec >= s.start && (endSec == null || currentSec < endSec)
}
if (seg != activeSegment) activeSegment = seg
delay(if (areControlsVisible) 300L else 600L)
}
}
DisposableEffect(Unit) {
@@ -276,11 +220,43 @@ fun PlayerScreen(
exoPlayer.play()
resetHideTimer()
}
fun loadSeasonEpisodes(seasonNum: Int) {
suspend fun focusEpisodeCard() {
var tries = 0
while (tries < 15) {
tries++
delay(60)
try {
episodeFocusRequester.requestFocus()
return
} catch (_: Exception) {}
}
}
fun loadSeasonEpisodes(seasonNum: Int, focusFirst: Boolean = false) {
selectedSeasonNum = seasonNum
coroutineScope.launch {
val eps = TmdbApi.getSeasonEpisodes(item.id, seasonNum)
allEpisodes = eps
if (focusFirst && eps.isNotEmpty()) {
episodeFocusIndex = 0
episodesListState.scrollToItem(0)
focusEpisodeCard()
}
}
}
LaunchedEffect(isEpisodesDrawerOpen) {
if (isEpisodesDrawerOpen) {
withTimeoutOrNull(5000) { snapshotFlow { allEpisodes }.first { it.isNotEmpty() } }
val ep = currentEpisode
val idx = if (ep != null) {
allEpisodes.indexOfFirst {
it.seasonNumber == ep.seasonNumber && it.episodeNumber == ep.episodeNumber
}
} else {
-1
}
episodeFocusIndex = if (idx >= 0) idx else 0
episodesListState.scrollToItem(episodeFocusIndex)
focusEpisodeCard()
}
}
LaunchedEffect(item) {
@@ -304,7 +280,7 @@ fun PlayerScreen(
val s = ep?.seasonNumber
val e = ep?.episodeNumber
coroutineScope.launch {
val segList = IntroDbApi.getTimestamps(item.id, s, e)
val segList = IntroDbV3.getSegments(item.id, s, e)
segments.clear()
segments.addAll(segList)
}
@@ -333,9 +309,29 @@ fun PlayerScreen(
}
)
}
LaunchedEffect(activeStreamUrl) {
if (activeStreamUrl == null) return@LaunchedEffect
var waited = 0
while (exoPlayer.duration <= 0L && waited < 30) {
delay(500)
waited++
}
val dur = exoPlayer.duration
if (dur > 0L) {
val ep = currentEpisode
val refined = IntroDbV3.getSegments(item.id, ep?.seasonNumber, ep?.episodeNumber, dur)
if (refined.isNotEmpty()) {
segments.clear()
segments.addAll(refined)
}
}
}
LaunchedEffect(Unit) {
showControlsAndFocusPlay()
}
BackHandler {
when {
activeMenu != ActivePlayerMenu.NONE -> {
@@ -399,6 +395,27 @@ fun PlayerScreen(
}
}
}
fun skipSegment(seg: SkipSegment) {
val endSec = seg.end
if (endSec == null) {
if (isNextEpAvailable) {
playNextEpisode()
} else if (durationMs > 0L) {
exoPlayer.seekTo(durationMs)
}
} else {
val targetMs = (endSec * 1000.0).toLong() + 200L
exoPlayer.seekTo(if (durationMs > 0L) targetMs.coerceAtMost(durationMs) else targetMs)
}
activeSegment = null
resetHideTimer()
}
VideoTintSampler(active = areControlsVisible || activeSegment != null) {
playerViewRef?.videoSurfaceView
}
Box(
modifier = modifier
.fillMaxSize()
@@ -409,6 +426,14 @@ fun PlayerScreen(
val code = event.nativeKeyEvent.keyCode
if (!areControlsVisible) {
val pendingSegment = activeSegment
val isConfirm = code == KeyEvent.KEYCODE_DPAD_CENTER ||
code == KeyEvent.KEYCODE_ENTER ||
code == KeyEvent.KEYCODE_NUMPAD_ENTER
if (pendingSegment != null && isConfirm && activeMenu == ActivePlayerMenu.NONE && !isEpisodesDrawerOpen) {
skipSegment(pendingSegment)
return@onKeyEvent true
}
if (code == KeyEvent.KEYCODE_DPAD_CENTER) {
if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play()
}
@@ -476,9 +501,12 @@ fun PlayerScreen(
)
}
},
update = { view ->
if (playerViewRef !== view) playerViewRef = view
view.resizeMode = if (isFillScreen) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT
},
modifier = Modifier.fillMaxSize()
)
if (isBuffering) {
@@ -501,12 +529,8 @@ fun PlayerScreen(
PlayerPillButton(
text = seg.label,
icon = Icons.Default.FastForward,
onClick = {
val targetMs = ((seg.end ?: ((currentPositionMs / 1000.0) + 30.0)) + 0.5) * 1000.0
exoPlayer.seekTo(targetMs.toLong().coerceAtMost(durationMs))
activeSegment = null
resetHideTimer()
}
focusRequester = skipButtonFocusRequester,
onClick = { skipSegment(seg) }
)
}
if (showNextPrompt) {
@@ -514,6 +538,7 @@ fun PlayerScreen(
text = "Next Episode",
icon = Icons.Default.SkipNext,
isPrimary = true,
focusRequester = nextButtonFocusRequester,
onClick = {
playNextEpisode()
resetHideTimer()
@@ -725,7 +750,13 @@ fun PlayerScreen(
},
onUpPress = {
try {
moreButtonFocusRequester.requestFocus()
if (seg != null) {
skipButtonFocusRequester.requestFocus()
} else if (showNextPrompt) {
nextButtonFocusRequester.requestFocus()
} else {
moreButtonFocusRequester.requestFocus()
}
} catch (_: Exception) {}
}
)
@@ -1024,7 +1055,7 @@ fun PlayerScreen(
season = season,
isSelected = selectedSeasonNum == season.seasonNumber,
onSelect = {
loadSeasonEpisodes(season.seasonNumber)
loadSeasonEpisodes(season.seasonNumber, true)
}
)
}
@@ -1044,16 +1075,18 @@ fun PlayerScreen(
)
}
LazyRow(
state = episodesListState,
contentPadding = PaddingValues(horizontal = 36.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier.padding(top = 16.dp)
) {
items(allEpisodes) { ep ->
itemsIndexed(allEpisodes) { index, ep ->
val isCur = currentEpisode?.seasonNumber == ep.seasonNumber &&
currentEpisode?.episodeNumber == ep.episodeNumber
DrawerEpisodeCard(
episode = ep,
isCurrentlyPlaying = isCur,
focusRequester = if (index == episodeFocusIndex) episodeFocusRequester else null,
onClick = {
currentEpisode = ep
isEpisodesDrawerOpen = false
@@ -1084,14 +1117,7 @@ private fun PlayerCircleButton(
.scale(scale)
.size(46.dp)
.clip(CircleShape)
.background(
when {
isFocused -> Color.White
isSelected -> Color(0x55FFFFFF)
else -> Color(0x22FFFFFF)
}
)
.then(if (isFocused) Modifier.border(2.dp, Color.White, CircleShape) else Modifier)
.videoTint(isFocused, isSelected)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { isFocused = it.isFocused }
.onKeyEvent { event ->
@@ -1130,11 +1156,7 @@ private fun PlayerEpisodesButton(
.scale(scale)
.size(46.dp)
.clip(CircleShape)
.background(if (isFocused) Color.White else Color(0x2EFFFFFF))
.then(
if (isFocused) Modifier.border(2.dp, Color(0xFF007AFF), CircleShape)
else Modifier.border(1.dp, Color(0x33FFFFFF), CircleShape)
)
.videoTint(isFocused)
.focusRequester(focusRequester)
.onFocusChanged { isFocused = it.isFocused }
.onKeyEvent { event ->
@@ -1181,8 +1203,7 @@ private fun PlayerCenterPlayButton(
.scale(scale)
.size(100.dp)
.clip(CircleShape)
.background(if (isFocused) Color(0x44FFFFFF) else Color(0x18FFFFFF))
.then(if (isFocused) Modifier.border(2.5.dp, Color.White, CircleShape) else Modifier)
.videoTint(isFocused)
.focusRequester(focusRequester)
.onFocusChanged { isFocused = it.isFocused }
.onKeyEvent { event ->
@@ -1201,7 +1222,7 @@ private fun PlayerCenterPlayButton(
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = Color.White,
tint = if (isFocused) Color.Black else Color.White,
modifier = Modifier.size(60.dp)
)
}
@@ -1211,6 +1232,7 @@ private fun PlayerPillButton(
text: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
isPrimary: Boolean = false,
focusRequester: FocusRequester? = null,
onClick: () -> Unit
) {
var isFocused by remember { mutableStateOf(false) }
@@ -1222,14 +1244,14 @@ private fun PlayerPillButton(
.scale(scale)
.height(44.dp)
.clip(RoundedCornerShape(22.dp))
.background(
when {
isFocused -> Color.White
isPrimary -> Color(0xE6FFFFFF)
else -> Color(0x33FFFFFF)
.then(
if (isPrimary) {
Modifier.background(if (isFocused) Color(0xF2FFFFFF) else Color(0xE6FFFFFF))
} else {
Modifier.videoTint(isFocused)
}
)
.then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(22.dp)) else Modifier)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { isFocused = it.isFocused }
.focusable()
.clickable(
@@ -1470,6 +1492,7 @@ private fun MiniSelectButton(
private fun DrawerEpisodeCard(
episode: Episode,
isCurrentlyPlaying: Boolean,
focusRequester: FocusRequester? = null,
onClick: () -> Unit
) {
var isFocused by remember { mutableStateOf(false) }
@@ -1478,6 +1501,7 @@ private fun DrawerEpisodeCard(
modifier = Modifier
.width(260.dp)
.scale(scale)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { isFocused = it.isFocused }
.focusable()
.clickable(
@@ -1493,7 +1517,7 @@ private fun DrawerEpisodeCard(
.background(Color(0xFF101016))
.then(
if (isFocused) Modifier.border(2.5.dp, Color.White, RoundedCornerShape(10.dp))
else if (isCurrentlyPlaying) Modifier.border(2.dp, Color(0xFF007AFF), RoundedCornerShape(10.dp))
else if (isCurrentlyPlaying) Modifier.border(2.dp, Color(0xB3FFFFFF), RoundedCornerShape(10.dp))
else Modifier.border(1.dp, Color(0x22FFFFFF), RoundedCornerShape(10.dp))
)
) {
@@ -1538,13 +1562,13 @@ private fun DrawerEpisodeCard(
modifier = Modifier
.padding(8.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xCC007AFF))
.background(Color(0xE6FFFFFF))
.padding(horizontal = 8.dp, vertical = 3.dp)
.align(Alignment.BottomEnd)
) {
Text(
text = "PLAYING",
color = Color.White,
color = Color.Black,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
@@ -1582,14 +1606,7 @@ private fun SeasonSelectChip(
modifier = Modifier
.height(36.dp)
.clip(RoundedCornerShape(18.dp))
.background(
when {
isFocused -> Color.White
isSelected -> Color(0x44FFFFFF)
else -> Color(0x22FFFFFF)
}
)
.then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(18.dp)) else Modifier)
.videoTint(isFocused, isSelected)
.onFocusChanged { isFocused = it.isFocused }
.focusable()
.clickable(

View File

@@ -1,96 +1,56 @@
package tv.vyla.app.ui.screens
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Backspace
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import coil.compose.AsyncImage
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import tv.vyla.app.api.TmdbApi
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
fun SearchScreen(
@@ -174,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()
@@ -214,7 +162,7 @@ fun SearchScreen(
.height(64.dp)
) {
Icon(
imageVector = Icons.Default.Search,
imageVector = SearchIcons.Search,
contentDescription = null,
tint = Color(0xD9FFFFFF),
modifier = Modifier.size(38.dp)
@@ -235,13 +183,14 @@ fun SearchScreen(
.padding(start = 6.dp)
.width(3.dp)
.height(30.dp)
.background(Color.White.copy(alpha = cursorAlpha))
.drawBehind { drawRect(Color.White, alpha = cursorAlpha) }
)
}
}
}
}
item {
val keyboardState = rememberLazyListState()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
@@ -256,37 +205,62 @@ fun SearchScreen(
onClick = {
isNumbersMode = !isNumbersMode
coroutineScope.launch { listState.animateScrollToItem(0) }
},
onFocus = {
coroutineScope.launch { listState.animateScrollToItem(0) }
}
)
SearchKeyButton(
label = "SPACE",
label = "",
isWord = true,
icon = SearchIcons.Space,
description = "Space",
onClick = {
if (query.isNotEmpty() && !query.endsWith(" ")) {
query += " "
performSearch(query)
}
coroutineScope.launch { listState.animateScrollToItem(0) }
},
onFocus = {
coroutineScope.launch { listState.animateScrollToItem(0) }
}
)
val activeKeys = if (isNumbersMode) numbersAndSymbols else letters
activeKeys.forEachIndexed { idx, keyChar ->
val isFirstLetter = !isNumbersMode && idx == 0
SearchKeyButton(
label = keyChar,
isWord = false,
focusRequester = if (isFirstLetter) letterAFocusRequester else null,
onClick = {
query += keyChar
performSearch(query)
coroutineScope.launch { listState.animateScrollToItem(0) }
}
)
LazyRow(
state = keyboardState,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f)
) {
val activeKeys = if (isNumbersMode) numbersAndSymbols else letters
items(activeKeys.size) { idx ->
val keyChar = activeKeys[idx]
val isFirstLetter = !isNumbersMode && idx == 0
SearchKeyButton(
label = keyChar,
isWord = false,
focusRequester = if (isFirstLetter) letterAFocusRequester else null,
onClick = {
query += keyChar
performSearch(query)
coroutineScope.launch { listState.animateScrollToItem(0) }
},
onFocus = {
coroutineScope.launch {
val viewportWidth = keyboardState.layoutInfo.viewportEndOffset - keyboardState.layoutInfo.viewportStartOffset
keyboardState.animateScrollToItem(
idx,
-(viewportWidth / 2 - 20)
)
}
}
)
}
}
SearchKeyButton(
label = "",
isWord = false,
isDelete = true,
icon = SearchIcons.Backspace,
description = "Backspace",
onClick = {
if (query.isNotEmpty()) {
query = query.dropLast(1)
@@ -321,6 +295,9 @@ fun SearchScreen(
query = name
performSearch(query)
coroutineScope.launch { listState.animateScrollToItem(0) }
},
onFocus = {
coroutineScope.launch { listState.animateScrollToItem(0) }
}
)
}
@@ -328,18 +305,36 @@ fun SearchScreen(
}
}
item {
val tabs = if (query.isNotBlank()) {
listOf("Top Results", "Movies", "TV Shows")
} else {
listOf("Top Results")
}
LaunchedEffect(query.isBlank()) {
if (query.isBlank() && selectedTab != 0) {
selectedTab = 0
}
}
Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 18.dp)) {
HorizontalDivider(color = Color(0x38FFFFFF), thickness = 1.dp)
Spacer(modifier = Modifier.height(14.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
horizontalArrangement = if (tabs.size > 1) Arrangement.SpaceAround else Arrangement.Center
) {
listOf("Top Results", "Movies", "TV Shows").forEachIndexed { idx, title ->
tabs.forEachIndexed { idx, title ->
SearchTabButton(
title = title,
isSelected = selectedTab == idx,
onClick = { selectedTab = idx }
onClick = {
selectedTab = idx
coroutineScope.launch { listState.animateScrollToItem(0) }
},
onFocus = {
coroutineScope.launch { listState.animateScrollToItem(0) }
}
)
}
}
@@ -412,10 +407,12 @@ fun SearchScreen(
private fun SearchKeyButton(
label: String,
isWord: Boolean,
isDelete: Boolean = false,
icon: ImageVector? = null,
description: String? = null,
focusRequester: FocusRequester? = null,
onLeftPress: (() -> Unit)? = null,
onClick: () -> Unit
onClick: () -> Unit,
onFocus: () -> Unit = {}
) {
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (isFocused) 1.22f else 1.0f, label = "key_scale")
@@ -437,7 +434,10 @@ private fun SearchKeyButton(
.clip(RoundedCornerShape(10.dp))
.background(bg)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { isFocused = it.isFocused }
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) onFocus()
}
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && onLeftPress != null) {
onLeftPress()
@@ -453,12 +453,12 @@ private fun SearchKeyButton(
.padding(horizontal = if (isWord) 12.dp else 0.dp)
.zIndex(if (isFocused) 5f else 1f)
) {
if (isDelete) {
if (icon != null) {
Icon(
imageVector = Icons.Default.Backspace,
contentDescription = "Backspace",
imageVector = icon,
contentDescription = description,
tint = contentColor,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(if (isWord) 22.dp else 20.dp)
)
} else {
Text(
@@ -474,7 +474,8 @@ private fun SearchKeyButton(
private fun SearchChip(
text: String,
showSearchIcon: Boolean = false,
onClick: () -> Unit
onClick: () -> Unit,
onFocus: () -> Unit = {}
) {
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (isFocused) 1.06f else 1.0f, label = "chip_scale")
@@ -486,7 +487,10 @@ private fun SearchChip(
.height(40.dp)
.clip(RoundedCornerShape(12.dp))
.background(if (isFocused) Color.White else Color(0x3DFFFFFF))
.onFocusChanged { isFocused = it.isFocused }
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) onFocus()
}
.focusable()
.clickable(
interactionSource = remember { MutableInteractionSource() },
@@ -496,7 +500,7 @@ private fun SearchChip(
) {
if (showSearchIcon) {
Icon(
imageVector = Icons.Default.Search,
imageVector = SearchIcons.Search,
contentDescription = null,
tint = if (isFocused) Color.Black else Color.White,
modifier = Modifier.size(16.dp)
@@ -514,7 +518,8 @@ private fun SearchChip(
private fun SearchTabButton(
title: String,
isSelected: Boolean,
onClick: () -> Unit
onClick: () -> Unit,
onFocus: () -> Unit = {}
) {
var isFocused by remember { mutableStateOf(false) }
Column(
@@ -522,7 +527,10 @@ private fun SearchTabButton(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(if (isFocused) Color(0x33FFFFFF) else Color.Transparent)
.onFocusChanged { isFocused = it.isFocused }
.onFocusChanged {
isFocused = it.isFocused
if (it.isFocused) onFocus()
}
.focusable()
.clickable(
interactionSource = remember { MutableInteractionSource() },
@@ -536,13 +544,6 @@ private fun SearchTabButton(
fontSize = 15.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.width(28.dp)
.height(2.dp)
.background(if (isSelected) Color.White else Color.Transparent)
)
}
}
@Composable
@@ -556,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

View File

@@ -1,18 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

1525
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,14 @@
{
"name": "tv",
"version": "1.0.3",
"version": "1.0.6",
"description": "",
"type": "module",
"scripts": {
"build:web": "node -e \"const fs = require('fs'); fs.rmSync('www', { recursive: true, force: true }); fs.mkdirSync('www'); ['css', 'js', 'assets'].forEach(d => fs.cpSync(d, 'www/' + d, { recursive: true })); fs.copyFileSync('index.html', 'www/index.html');\"",
"dev": "node scripts/build-apk.mjs",
"release": "node scripts/release.mjs",
"android": "node scripts/build-android.mjs"
},
"dependencies": {
"@capacitor/android": "^6.2.2",
"@capacitor/cli": "^6.2.2",
"@capacitor/core": "^6.2.2",
"@vyla-entertainment/sdk": "^1.14.0",
"dotenv": "^16.4.5",
"express": "^4.19.2",

View File

@@ -1,6 +1,6 @@
{
"name": "Vyla TV",
"version": "1.0.2",
"package": "Vyla-TV_1.0.2.apk",
"releaseDate": "2026-10-05T05:03:55.138Z"
"version": "1.0.5",
"package": "Vyla-TV_1.0.5.apk",
"releaseDate": "2026-10-05T22:49:51.172Z"
}

View File

@@ -38,7 +38,6 @@ const version = pkg.version || '1.0.0';
const tag = `v${version}`;
run('node', ['scripts/build-android.mjs']);
run('npx', ['cap', 'sync', 'android']);
run(win ? 'gradlew.bat' : './gradlew', ['assembleRelease'], androidDir);
const relDir = path.join(androidDir, 'app/build/outputs/apk/release');