4 Commits

Author SHA1 Message Date
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
23 changed files with 999 additions and 2134 deletions

View File

@@ -1,10 +1,3 @@
// 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',
@@ -239,7 +232,7 @@ export const SOURCES = [
retries: 1, retries: 1,
multiUrl: true, multiUrl: true,
skipProxy: true, skipProxy: true,
disabled: false, // Enabled, but only returns streams when a manifest is provided disabled: false,
}, },
{ {

View File

@@ -1,8 +1,3 @@
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,5 +1,3 @@
// 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 = [

5
android/.gitignore vendored
View File

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

@@ -52,7 +52,7 @@ class MainActivity : ComponentActivity() {
val contentFocusRequester = remember { FocusRequester() } val contentFocusRequester = remember { FocusRequester() }
BackHandler(enabled = playingItem == null && activeDetailItem == null) { BackHandler(enabled = playingItem == null && activeDetailItem == null) {
if (currentDestination != NavDestination.DISCOVERY) { if (currentDestination != NavDestination.DISCOVERY && currentDestination != NavDestination.ANIME) {
currentDestination = NavDestination.DISCOVERY currentDestination = NavDestination.DISCOVERY
} else { } else {
finish() finish()
@@ -68,6 +68,7 @@ class MainActivity : ComponentActivity() {
onNavigate = { dest -> onNavigate = { dest ->
currentDestination = dest currentDestination = dest
activeDetailItem = null activeDetailItem = null
contentFocusRequester.requestFocus()
}, },
onRequestExitSidebar = { onRequestExitSidebar = {
try { try {
@@ -88,7 +89,8 @@ class MainActivity : ComponentActivity() {
when (currentDestination) { when (currentDestination) {
NavDestination.DISCOVERY, NavDestination.DISCOVERY,
NavDestination.MOVIES, NavDestination.MOVIES,
NavDestination.TV_SHOWS -> { NavDestination.TV_SHOWS,
NavDestination.ANIME -> {
BrowseScreen( BrowseScreen(
mode = currentDestination, mode = currentDestination,
onOpenDetail = { item -> activeDetailItem = item }, onOpenDetail = { item -> activeDetailItem = item },

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) 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) { suspend fun getTrending(mediaType: String): List<MediaItem> = withContext(Dispatchers.IO) {
val json = fetchJson("/trending/$mediaType/week") ?: return@withContext emptyList() val json = fetchJson("/trending/$mediaType/week") ?: return@withContext emptyList()
parseMediaList(json.optJSONArray("results"), mediaType) parseMediaList(json.optJSONArray("results"), mediaType)

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,17 +36,27 @@ import androidx.compose.ui.zIndex
import tv.vyla.app.R import tv.vyla.app.R
import tv.vyla.app.ui.theme.VylaTextMuted import tv.vyla.app.ui.theme.VylaTextMuted
import tv.vyla.app.ui.theme.VylaTextPrimary 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) { enum class NavDestination(val key: String, val title: String, val icon: ImageVector) {
SEARCH("search", "Search", Icons.Default.Search), SEARCH("search", "Search", Icons.Default.Search),
DISCOVERY("discover", "Discovery", Icons.Default.Explore), DISCOVERY("discover", "Discovery", Icons.Default.Explore),
MOVIES("movie", "Movies", Icons.Default.Movie), MOVIES("movie", "Movies", Icons.Default.Movie),
TV_SHOWS("tv", "TV Shows", Icons.Default.Tv), TV_SHOWS("tv", "TV Shows", Icons.Default.Tv),
ANIME("anime", "Anime", Icons.Default.AutoAwesome),
WATCH_LATER("wl", "Watch Later", Icons.Default.Schedule), WATCH_LATER("wl", "Watch Later", Icons.Default.Schedule),
FAVORITES("fav", "Favorites", Icons.Default.Favorite) FAVORITES("fav", "Favorites", Icons.Default.Favorite)
} }
@Composable @Composable
@OptIn(ExperimentalComposeUiApi::class)
fun Sidebar( fun Sidebar(
currentDestination: NavDestination, currentDestination: NavDestination,
hasWatchLater: Boolean, hasWatchLater: Boolean,
@@ -57,102 +67,112 @@ fun Sidebar(
) { ) {
var focusedDestination by remember { mutableStateOf<NavDestination?>(null) } var focusedDestination by remember { mutableStateOf<NavDestination?>(null) }
val isSidebarFocused = focusedDestination != null val isSidebarFocused = focusedDestination != null
val destinationFocusRequesters = remember { mutableMapOf<NavDestination, FocusRequester>() }
val animatedWidth by animateDpAsState( val animatedWidth by animateDpAsState(
targetValue = if (isSidebarFocused) 260.dp else 68.dp, targetValue = if (isSidebarFocused) 220.dp else 58.dp,
animationSpec = tween(durationMillis = 280), animationSpec = tween(durationMillis = 220),
label = "sidebar_width" label = "sidebar_width"
) )
LaunchedEffect(Unit) {
delay(60)
try {
destinationFocusRequesters[currentDestination]?.requestFocus()
} catch (_: Exception) {}
}
Box( Box(
modifier = modifier modifier = modifier
.fillMaxHeight() .fillMaxHeight()
.width(animatedWidth) .width(animatedWidth)
.zIndex(10f) .zIndex(10f)
.focusProperties {
enter = { destinationFocusRequesters[currentDestination] ?: FocusRequester.Default }
}
.focusGroup()
.background( .background(
Brush.horizontalGradient( Brush.horizontalGradient(
colors = if (isSidebarFocused) { colors = if (isSidebarFocused) {
listOf(Color(0xF0000000), Color(0xB3000000), Color.Transparent) listOf(Color(0xF207070A), Color(0xCC07070A), Color.Transparent)
} else { } else {
listOf(Color(0xCC000000), Color.Transparent) listOf(Color(0xD907070A), Color.Transparent)
} }
) )
) )
.padding(vertical = 24.dp) .padding(vertical = 20.dp)
) { ) {
Column( Row(
modifier = Modifier.fillMaxHeight(), verticalAlignment = Alignment.CenterVertically,
verticalArrangement = Arrangement.SpaceBetween modifier = Modifier
.align(Alignment.TopStart)
.padding(start = 14.dp, top = 2.dp)
.height(36.dp)
) { ) {
Row( Box(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .size(30.dp)
.height(44.dp) .clip(CircleShape)
.background(Color.White),
contentAlignment = Alignment.Center
) { ) {
Box( androidx.compose.foundation.Image(
modifier = Modifier painter = painterResource(R.drawable.icon),
.size(36.dp) contentDescription = "Vyla Logo",
.clip(CircleShape) modifier = Modifier.size(24.dp),
.background(Color.White), contentScale = ContentScale.Fit
contentAlignment = Alignment.Center )
) { }
androidx.compose.foundation.Image(
painter = painterResource(R.drawable.icon), AnimatedVisibility(
contentDescription = "Vyla Logo", visible = isSidebarFocused,
modifier = Modifier.size(32.dp), enter = fadeIn(tween(160)),
contentScale = ContentScale.Fit 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.WATCH_LATER -> hasWatchLater || currentDestination == NavDestination.WATCH_LATER
NavDestination.FAVORITES -> hasFavorites || currentDestination == NavDestination.FAVORITES
else -> true
} }
AnimatedVisibility( if (shouldShow) {
visible = isSidebarFocused, val requester = destinationFocusRequesters.getOrPut(destination) { FocusRequester() }
enter = fadeIn(tween(200)), SidebarItem(
exit = fadeOut(tween(150)) destination = destination,
) { isSelected = currentDestination == destination,
Text( isExpanded = isSidebarFocused,
text = "Vyla", focusRequester = requester,
color = Color.White, onFocusGained = { focusedDestination = destination },
fontSize = 22.sp, onFocusLost = { if (focusedDestination == destination) focusedDestination = null },
fontWeight = FontWeight.Bold, onSelect = {
letterSpacing = (-0.5).sp, focusedDestination = null
modifier = Modifier.padding(start = 14.dp) 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 +182,7 @@ private fun SidebarItem(
destination: NavDestination, destination: NavDestination,
isSelected: Boolean, isSelected: Boolean,
isExpanded: Boolean, isExpanded: Boolean,
focusRequester: FocusRequester? = null,
onFocusGained: () -> Unit, onFocusGained: () -> Unit,
onFocusLost: () -> Unit, onFocusLost: () -> Unit,
onSelect: () -> Unit, onSelect: () -> Unit,
@@ -169,38 +190,37 @@ private fun SidebarItem(
) { ) {
var isItemFocused by remember { mutableStateOf(false) } var isItemFocused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(26.dp)
val background = when { val background = when {
isItemFocused -> Color.White isItemFocused -> Color.White
isSelected && !isExpanded -> Color(0x33FFFFFF)
else -> Color.Transparent else -> Color.Transparent
} }
val contentColor = when { val contentColor = when {
isItemFocused -> Color.Black isItemFocused -> Color.Black
isSelected -> Color.White isSelected -> Color.White
else -> VylaTextMuted else -> Color(0x99FFFFFF)
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(52.dp) .height(42.dp)
.clip(shape) .clip(RoundedCornerShape(21.dp))
.background(background) .background(background)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { state -> .onFocusChanged { state ->
isItemFocused = state.isFocused isItemFocused = state.isFocused
if (state.isFocused) onFocusGained() else onFocusLost() if (state.isFocused) onFocusGained() else onFocusLost()
} }
.onKeyEvent { event -> .onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown) { if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight) {
if (event.key == Key.DirectionRight) { onRightPress()
onRightPress() true
return@onKeyEvent true } else {
} false
} }
false
} }
.focusable() .focusable()
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@@ -208,27 +228,31 @@ private fun SidebarItem(
) { ) {
onSelect() onSelect()
} }
.padding(horizontal = 12.dp)
) { ) {
Icon( Box(
imageVector = destination.icon, modifier = Modifier.size(42.dp),
contentDescription = destination.title, contentAlignment = Alignment.Center
tint = contentColor, ) {
modifier = Modifier.size(24.dp) Icon(
) imageVector = destination.icon,
contentDescription = destination.title,
tint = contentColor,
modifier = Modifier.size(22.dp)
)
}
AnimatedVisibility( AnimatedVisibility(
visible = isExpanded, visible = isExpanded,
enter = fadeIn(tween(200)), enter = fadeIn(tween(160)),
exit = fadeOut(tween(150)) exit = fadeOut(tween(100))
) { ) {
Text( Text(
text = destination.title, text = destination.title,
color = contentColor, color = contentColor,
fontSize = 17.sp, fontSize = 15.sp,
fontWeight = if (isSelected || isItemFocused) FontWeight.Bold else FontWeight.Medium, fontWeight = if (isSelected || isItemFocused) FontWeight.Bold else FontWeight.Medium,
maxLines = 1, 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(400)
}
}
}
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(96, 54)
.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,62 @@
package tv.vyla.app.ui.screens package tv.vyla.app.ui.screens
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.* import androidx.compose.ui.input.key.*
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tv.vyla.app.api.TmdbApi import tv.vyla.app.api.TmdbApi
import tv.vyla.app.model.Genre import tv.vyla.app.model.Genre
import tv.vyla.app.model.MediaItem import tv.vyla.app.model.MediaItem
import tv.vyla.app.ui.components.MediaCard import tv.vyla.app.ui.components.MediaCard
import tv.vyla.app.ui.components.NavDestination import tv.vyla.app.ui.components.NavDestination
import tv.vyla.app.ui.theme.* 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
data class GenreRow( data class GenreRow(
@@ -112,8 +68,9 @@ data class GenreRow(
val items: List<MediaItem> val items: List<MediaItem>
) )
@Composable
@Composable
@OptIn(ExperimentalComposeUiApi::class)
fun BrowseScreen( fun BrowseScreen(
mode: NavDestination, mode: NavDestination,
onOpenDetail: (MediaItem) -> Unit, onOpenDetail: (MediaItem) -> Unit,
@@ -131,6 +88,11 @@ fun BrowseScreen(
val movieGenres = remember { mutableStateListOf<Genre>() } val movieGenres = remember { mutableStateListOf<Genre>() }
val tvGenres = remember { mutableStateListOf<Genre>() } val tvGenres = remember { mutableStateListOf<Genre>() }
val playButtonFocusRequester = remember { FocusRequester() } 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) }
fun updateHero(item: MediaItem) { fun updateHero(item: MediaItem) {
heroJob?.cancel() heroJob?.cancel()
heroJob = coroutineScope.launch { heroJob = coroutineScope.launch {
@@ -139,26 +101,49 @@ fun BrowseScreen(
heroLogoUrl = TmdbApi.getLogo(item.mediaType, item.id) heroLogoUrl = TmdbApi.getLogo(item.mediaType, item.id)
} }
} }
suspend fun loadMoreRows(count: Int) {
if (isLoadingMore) return suspend fun loadMoreRows(count: Int) {
isLoadingMore = true if (isLoadingMore) return
val isMovieMode = mode == NavDestination.MOVIES isLoadingMore = true
val isTvMode = mode == NavDestination.TV_SHOWS val generation = loadGeneration
val isDiscover = mode == NavDestination.DISCOVERY val activeMode = currentMode
for (i in 0 until count) { 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 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 val list = if (isMovie) movieGenres else tvGenres
if (list.isEmpty()) continue if (list.isEmpty()) continue
val genreIdx = if (isDiscover) curI / 2 else curI val genreIdx = if (isDiscover) curI / 2 else curI
val genre = list[genreIdx % list.size] val genre = list[genreIdx % list.size]
val page = 1 + (genreIdx / list.size) % 15 val page = 1 + (genreIdx / list.size) % 15
val mt = if (isMovie) "movie" else "tv" 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()) { 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)) rows.add(GenreRow(title, mt, genre.id, items))
if (heroItem == null && rows.isNotEmpty()) { added++
if (heroItem == null) {
val first = rows[0].items.firstOrNull() val first = rows[0].items.firstOrNull()
if (first != null) { if (first != null) {
heroItem = first heroItem = first
@@ -169,20 +154,66 @@ fun BrowseScreen(
} }
} }
} }
isLoadingMore = false } finally {
if (generation == loadGeneration) isLoadingMore = false
} }
LaunchedEffect(mode) { }
rows.clear()
rowIdx = 0 fun restoreContentFocus(): Boolean {
heroItem = null val cardRequester = lastFocusedKey?.let { cardRequesters[it] }
heroLogoUrl = null if (cardRequester != null) {
movieGenres.clear() try {
tvGenres.clear() cardRequester.requestFocus()
movieGenres.addAll(TmdbApi.getGenreList("movie")) return true
tvGenres.addAll(TmdbApi.getGenreList("tv")) } catch (_: Exception) {}
loadMoreRows(5)
} }
val listState = rememberLazyListState() 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
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)) { Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
Crossfade( Crossfade(
targetState = heroItem?.ambientBackdropUrl, targetState = heroItem?.ambientBackdropUrl,
@@ -222,6 +253,7 @@ fun BrowseScreen(
logoUrl = heroLogoUrl, logoUrl = heroLogoUrl,
playButtonFocusRequester = playButtonFocusRequester, playButtonFocusRequester = playButtonFocusRequester,
onFocusedTop = { onFocusedTop = {
lastFocusedKey = null
coroutineScope.launch { coroutineScope.launch {
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
} }
@@ -237,6 +269,8 @@ fun BrowseScreen(
) )
} }
items(rows) { row -> items(rows) { row ->
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,
@@ -246,17 +280,36 @@ fun BrowseScreen(
modifier = Modifier.padding(start = 78.dp, bottom = 12.dp) modifier = Modifier.padding(start = 78.dp, bottom = 12.dp)
) )
LazyRow( LazyRow(
state = rowState,
contentPadding = PaddingValues(horizontal = 78.dp), contentPadding = PaddingValues(horizontal = 78.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
items(row.items) { item -> items(row.items) { item ->
val index = row.items.indexOf(item)
val isFirstInRow = row.items.firstOrNull() == item val isFirstInRow = row.items.firstOrNull() == item
val cardKey = "${row.title}:${item.id}"
val cardRequester = cardRequesters.getOrPut(cardKey) { FocusRequester() }
MediaCard( MediaCard(
item = item, item = item,
width = 136, width = 136,
onSelect = { onOpenDetail(item) }, onSelect = { onOpenDetail(item) },
onFocused = { updateHero(item) }, onFocused = {
modifier = Modifier.onKeyEvent { event -> lastFocusedKey = cardKey
updateHero(item)
coroutineScope.launch {
val viewportWidth = rowState.layoutInfo.viewportEndOffset - rowState.layoutInfo.viewportStartOffset
rowState.animateScrollToItem(
index,
-(viewportWidth / 2 - 68)
)
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) { if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionLeft && isFirstInRow) {
onRequestSidebar() onRequestSidebar()
return@onKeyEvent true return@onKeyEvent true
@@ -440,14 +493,10 @@ private fun HeroPillButton(
targetValue = if (isFocused) 1.05f else 1.0f, targetValue = if (isFocused) 1.05f else 1.0f,
label = "pill_scale" label = "pill_scale"
) )
val background = when { val primaryBackground = if (isFocused) Color.White else Color(0xD9FFFFFF)
isPrimary && isFocused -> Color.White
isPrimary -> Color.White
isFocused -> Color(0x55FFFFFF)
else -> VylaPillBackground
}
val contentColor = when { val contentColor = when {
isPrimary -> Color.Black isPrimary -> Color.Black
isFocused -> Color.Black
else -> Color.White else -> Color.White
} }
Row( Row(
@@ -457,13 +506,8 @@ private fun HeroPillButton(
.scale(scale) .scale(scale)
.height(48.dp) .height(48.dp)
.clip(RoundedCornerShape(24.dp)) .clip(RoundedCornerShape(24.dp))
.background(background)
.then( .then(
if (isFocused && isPrimary) { if (isPrimary) Modifier.background(primaryBackground) else Modifier.videoTint(isFocused)
Modifier.border(2.dp, Color(0xFFCCCCCC), RoundedCornerShape(24.dp))
} else if (isFocused) {
Modifier.border(2.dp, Color.White, RoundedCornerShape(24.dp))
} else Modifier
) )
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier) .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { .onFocusChanged {

View File

@@ -132,14 +132,16 @@ fun DetailScreen(
var isMenuOpen by remember { mutableStateOf(false) } var isMenuOpen by remember { mutableStateOf(false) }
var isSeasonDropdownOpen by remember { mutableStateOf(false) } var isSeasonDropdownOpen by remember { mutableStateOf(false) }
val playButtonFocusRequester = remember { FocusRequester() } val playButtonFocusRequester = remember { FocusRequester() }
val firstEpisodeFocusRequester = remember { FocusRequester() }
var shouldFocusFirstEpisodeAfterSeasonChange by remember { mutableStateOf(false) }
LaunchedEffect(item) { LaunchedEffect(item) {
listState.scrollToItem(0) listState.scrollToItem(0)
val d = TmdbApi.getDetail(item.mediaType, item.id) val d = TmdbApi.getDetail(item.mediaType, item.id)
detail = d detail = d
if (d != null && d.seasons.isNotEmpty()) { if (d != null && d.seasons.isNotEmpty()) {
val firstSeason = d.seasons.first() val initialSeason = d.seasons.firstOrNull { it.seasonNumber == 1 } ?: d.seasons.first()
selectedSeason = firstSeason selectedSeason = initialSeason
seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, firstSeason.seasonNumber) seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, initialSeason.seasonNumber)
} }
delay(150) delay(150)
try { try {
@@ -150,6 +152,13 @@ fun DetailScreen(
val s = selectedSeason val s = selectedSeason
if (s != null) { if (s != null) {
seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, s.seasonNumber) seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, s.seasonNumber)
if (shouldFocusFirstEpisodeAfterSeasonChange) {
shouldFocusFirstEpisodeAfterSeasonChange = false
delay(120)
try {
firstEpisodeFocusRequester.requestFocus()
} catch (_: Exception) {}
}
} }
} }
BackHandler { BackHandler {
@@ -387,6 +396,26 @@ fun DetailScreen(
} }
if (hasEpisodes) { if (hasEpisodes) {
item { 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -414,19 +443,22 @@ fun DetailScreen(
} }
if (isSeasonDropdownOpen) { if (isSeasonDropdownOpen) {
LazyRow( LazyRow(
state = seasonRowState,
contentPadding = PaddingValues(horizontal = 48.dp), contentPadding = PaddingValues(horizontal = 48.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier modifier = Modifier.padding(vertical = 18.dp)
.padding(vertical = 10.dp)
.background(Color(0x9916161E))
.padding(vertical = 8.dp)
) { ) {
items(d?.seasons ?: emptyList()) { season -> items(seasonsList) { season ->
val requester = seasonChipFocusRequesters.getOrPut(season.seasonNumber) {
FocusRequester()
}
SeasonSelectChip( SeasonSelectChip(
season = season, season = season,
isSelected = selectedSeason?.seasonNumber == season.seasonNumber, isSelected = selectedSeason?.seasonNumber == season.seasonNumber,
focusRequester = requester,
onFocused = { scrollToSection(episodesItemIndex) }, onFocused = { scrollToSection(episodesItemIndex) },
onSelect = { onSelect = {
shouldFocusFirstEpisodeAfterSeasonChange = true
selectedSeason = season selectedSeason = season
isSeasonDropdownOpen = false isSeasonDropdownOpen = false
} }
@@ -449,15 +481,17 @@ fun DetailScreen(
modifier = Modifier.padding(vertical = 16.dp) modifier = Modifier.padding(vertical = 16.dp)
) )
} else { } else {
rowsOfEpisodes.forEach { rowEpisodes -> rowsOfEpisodes.forEachIndexed { rowIndex, rowEpisodes ->
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(18.dp) horizontalArrangement = Arrangement.spacedBy(18.dp)
) { ) {
rowEpisodes.forEach { ep -> rowEpisodes.forEachIndexed { colIndex, ep ->
val isFirstEpisode = rowIndex == 0 && colIndex == 0
Box(modifier = Modifier.weight(1f)) { Box(modifier = Modifier.weight(1f)) {
DetailEpisodeCard( DetailEpisodeCard(
episode = ep, episode = ep,
focusRequester = if (isFirstEpisode) firstEpisodeFocusRequester else null,
onFocused = { scrollToSection(episodesItemIndex) }, onFocused = { scrollToSection(episodesItemIndex) },
onClick = { onPlay(item, ep) } onClick = { onPlay(item, ep) }
) )
@@ -770,10 +804,12 @@ private fun SeasonDropdownButton(
) )
} }
} }
@Composable @Composable
private fun SeasonSelectChip( private fun SeasonSelectChip(
season: Season, season: Season,
isSelected: Boolean, isSelected: Boolean,
focusRequester: FocusRequester? = null,
onFocused: (() -> Unit)? = null, onFocused: (() -> Unit)? = null,
onSelect: () -> Unit onSelect: () -> Unit
) { ) {
@@ -782,15 +818,15 @@ private fun SeasonSelectChip(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier modifier = Modifier
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background( .background(
when { when {
isFocused -> Color.White isFocused -> Color(0xF2FFFFFF)
isSelected -> Color(0x44FFFFFF) isSelected -> Color(0x66FFFFFF)
else -> Color(0x18FFFFFF) else -> Color(0x99020202)
} }
) )
.then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(12.dp)) else Modifier)
.onFocusChanged { .onFocusChanged {
isFocused = it.isFocused isFocused = it.isFocused
if (it.isFocused) onFocused?.invoke() if (it.isFocused) onFocused?.invoke()
@@ -816,9 +852,11 @@ private fun SeasonSelectChip(
) )
} }
} }
@Composable @Composable
private fun DetailEpisodeCard( private fun DetailEpisodeCard(
episode: Episode, episode: Episode,
focusRequester: FocusRequester? = null,
onFocused: (() -> Unit)? = null, onFocused: (() -> Unit)? = null,
onClick: () -> Unit onClick: () -> Unit
) { ) {
@@ -828,6 +866,7 @@ private fun DetailEpisodeCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.scale(scale) .scale(scale)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.onFocusChanged { .onFocusChanged {
isFocused = it.isFocused isFocused = it.isFocused
if (it.isFocused) onFocused?.invoke() if (it.isFocused) onFocused?.invoke()
@@ -906,6 +945,7 @@ private fun DetailEpisodeCard(
) )
} }
} }
@Composable @Composable
private fun DetailTrailerCard( private fun DetailTrailerCard(
trailer: VideoClip, trailer: VideoClip,
@@ -914,7 +954,7 @@ private fun DetailTrailerCard(
) { ) {
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")
Column( Box(
modifier = Modifier modifier = Modifier
.width(280.dp) .width(280.dp)
.scale(scale) .scale(scale)
@@ -930,53 +970,97 @@ private fun DetailTrailerCard(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { onClick() } ) { onClick() }
.padding(bottom = 14.dp)
) { ) {
Box( Column {
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()
)
Box( Box(
modifier = Modifier modifier = Modifier
.size(44.dp) .fillMaxWidth()
.clip(CircleShape) .aspectRatio(16f / 9f)
.background(Color(0x88000000))
.align(Alignment.Center),
contentAlignment = Alignment.Center
) { ) {
Icon( AsyncImage(
imageVector = Icons.Default.PlayArrow, model = trailer.thumbnailUrl,
contentDescription = null, contentDescription = trailer.name,
tint = Color.White, contentScale = ContentScale.Crop,
modifier = Modifier.size(26.dp) 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)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(40.dp)
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
0.0f to Color.Transparent,
1.0f to Color(0xE6101016)
)
)
) )
} }
Box(
modifier = Modifier
.fillMaxWidth()
.height(76.dp)
) {
AsyncImage(
model = trailer.thumbnailUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.blur(36.dp)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0.0f to Color(0xE6101016),
0.35f to Color(0x99101016),
1.0f to Color(0xF2101016)
)
)
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = trailer.name,
color = Color(0xFFF2F2F5),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "▶ ${trailer.type}",
color = Color(0xFFD0D0DA),
fontSize = 12.sp,
modifier = Modifier.padding(top = 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 @Composable
private fun DetailCastPortrait( private fun DetailCastPortrait(
member: CastMember, member: CastMember,

View File

@@ -17,6 +17,16 @@ import tv.vyla.app.ui.components.MediaCard
import tv.vyla.app.ui.theme.VylaBackground import tv.vyla.app.ui.theme.VylaBackground
import tv.vyla.app.ui.theme.VylaTextMuted import tv.vyla.app.ui.theme.VylaTextMuted
import tv.vyla.app.ui.theme.VylaTextPrimary 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
@Composable @Composable
fun LibraryScreen( fun LibraryScreen(
@@ -25,10 +35,38 @@ fun LibraryScreen(
onOpenDetail: (MediaItem) -> Unit, onOpenDetail: (MediaItem) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column( var ambientUrl by remember(items) { mutableStateOf(items.firstOrNull()?.ambientBackdropUrl) }
modifier = modifier Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
Crossfade(
targetState = ambientUrl,
animationSpec = tween(700),
label = "library_ambient"
) { url ->
if (!url.isNullOrEmpty()) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.blur(50.dp)
)
}
}
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() .fillMaxSize()
.background(VylaBackground)
.padding(start = 78.dp, end = 48.dp, top = 36.dp) .padding(start = 78.dp, end = 48.dp, top = 36.dp)
) { ) {
Row( Row(
@@ -75,10 +113,12 @@ fun LibraryScreen(
MediaCard( MediaCard(
item = item, item = item,
width = 140, width = 140,
onSelect = { onOpenDetail(item) } onSelect = { onOpenDetail(item) },
onFocused = { ambientUrl = it.ambientBackdropUrl }
) )
} }
} }
} }
} }
}
} }

View File

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

View File

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

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", "name": "tv",
"version": "1.0.3", "version": "1.0.4",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "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", "dev": "node scripts/build-apk.mjs",
"release": "node scripts/release.mjs", "release": "node scripts/release.mjs",
"android": "node scripts/build-android.mjs" "android": "node scripts/build-android.mjs"
}, },
"dependencies": { "dependencies": {
"@capacitor/android": "^6.2.2",
"@capacitor/cli": "^6.2.2",
"@capacitor/core": "^6.2.2",
"@vyla-entertainment/sdk": "^1.14.0", "@vyla-entertainment/sdk": "^1.14.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "express": "^4.19.2",

View File

@@ -1,6 +1,6 @@
{ {
"name": "Vyla TV", "name": "Vyla TV",
"version": "1.0.2", "version": "1.0.3",
"package": "Vyla-TV_1.0.2.apk", "package": "Vyla-TV_1.0.3.apk",
"releaseDate": "2026-10-05T05:03:55.138Z" "releaseDate": "2026-10-05T18:09:19.183Z"
} }

View File

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