From 102c3bdc84f25b95eda53f3b617d5a8644408a8f Mon Sep 17 00:00:00 2001 From: alwaysmesmerizingyou Date: Mon, 5 Oct 2026 13:21:01 -0700 Subject: [PATCH] great UI update --- .build/sdk/config.js | 9 +- .build/sdk/sources/pengu.js | 5 - .build/sdk/sources/rivestream.js | 2 - .../src/main/java/tv/vyla/app/MainActivity.kt | 6 +- .../main/java/tv/vyla/app/api/IntroDbApi.kt | 71 ----- .../main/java/tv/vyla/app/api/IntroDbV3.kt | 84 ++++++ .../src/main/java/tv/vyla/app/api/TmdbApi.kt | 6 + .../tv/vyla/app/ui/components/SearchIcons.kt | 48 ++++ .../java/tv/vyla/app/ui/components/Sidebar.kt | 210 +++++++------- .../tv/vyla/app/ui/components/VideoTint.kt | 187 +++++++++++++ .../tv/vyla/app/ui/screens/BrowseScreen.kt | 222 +++++++++------ .../tv/vyla/app/ui/screens/DetailScreen.kt | 190 +++++++++---- .../tv/vyla/app/ui/screens/LibraryScreen.kt | 50 +++- .../tv/vyla/app/ui/screens/PlayerScreen.kt | 258 +++++++++--------- .../tv/vyla/app/ui/screens/SearchScreen.kt | 180 ++++++------ 15 files changed, 992 insertions(+), 536 deletions(-) delete mode 100644 android/app/src/main/java/tv/vyla/app/api/IntroDbApi.kt create mode 100644 android/app/src/main/java/tv/vyla/app/api/IntroDbV3.kt create mode 100644 android/app/src/main/java/tv/vyla/app/ui/components/SearchIcons.kt create mode 100644 android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt diff --git a/.build/sdk/config.js b/.build/sdk/config.js index e0223a9..f551ebe 100644 --- a/.build/sdk/config.js +++ b/.build/sdk/config.js @@ -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 = [ { key: '123anime', @@ -239,7 +232,7 @@ export const SOURCES = [ retries: 1, multiUrl: true, skipProxy: true, - disabled: false, // Enabled, but only returns streams when a manifest is provided + disabled: false, }, { diff --git a/.build/sdk/sources/pengu.js b/.build/sdk/sources/pengu.js index ac31132..3cc4aa4 100644 --- a/.build/sdk/sources/pengu.js +++ b/.build/sdk/sources/pengu.js @@ -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) { const key = tmdbApiKey; if (!key) return null; diff --git a/.build/sdk/sources/rivestream.js b/.build/sdk/sources/rivestream.js index d4ebf25..e388c62 100644 --- a/.build/sdk/sources/rivestream.js +++ b/.build/sdk/sources/rivestream.js @@ -1,5 +1,3 @@ -// im being salty - import { USER_AGENT, fetchJson } from '../utils/helpers.js'; const C_ARRAY = [ diff --git a/android/app/src/main/java/tv/vyla/app/MainActivity.kt b/android/app/src/main/java/tv/vyla/app/MainActivity.kt index dd2893c..ab07458 100644 --- a/android/app/src/main/java/tv/vyla/app/MainActivity.kt +++ b/android/app/src/main/java/tv/vyla/app/MainActivity.kt @@ -52,7 +52,7 @@ class MainActivity : ComponentActivity() { 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() @@ -68,6 +68,7 @@ class MainActivity : ComponentActivity() { onNavigate = { dest -> currentDestination = dest activeDetailItem = null + contentFocusRequester.requestFocus() }, onRequestExitSidebar = { try { @@ -88,7 +89,8 @@ class MainActivity : ComponentActivity() { when (currentDestination) { NavDestination.DISCOVERY, NavDestination.MOVIES, - NavDestination.TV_SHOWS -> { + NavDestination.TV_SHOWS, + NavDestination.ANIME -> { BrowseScreen( mode = currentDestination, onOpenDetail = { item -> activeDetailItem = item }, diff --git a/android/app/src/main/java/tv/vyla/app/api/IntroDbApi.kt b/android/app/src/main/java/tv/vyla/app/api/IntroDbApi.kt deleted file mode 100644 index 4224f94..0000000 --- a/android/app/src/main/java/tv/vyla/app/api/IntroDbApi.kt +++ /dev/null @@ -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 = 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() - - 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() - } - } -} diff --git a/android/app/src/main/java/tv/vyla/app/api/IntroDbV3.kt b/android/app/src/main/java/tv/vyla/app/api/IntroDbV3.kt new file mode 100644 index 0000000..9492b27 --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/api/IntroDbV3.kt @@ -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 = 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 { + val result = ArrayList() + 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 } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt b/android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt index 37efdaf..68a5830 100644 --- a/android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt +++ b/android/app/src/main/java/tv/vyla/app/api/TmdbApi.kt @@ -52,6 +52,12 @@ object TmdbApi { parseMediaList(json.optJSONArray("results"), mediaType) } + suspend fun getAnimeDiscover(genreId: Int, page: Int): List = 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 = withContext(Dispatchers.IO) { val json = fetchJson("/trending/$mediaType/week") ?: return@withContext emptyList() parseMediaList(json.optJSONArray("results"), mediaType) diff --git a/android/app/src/main/java/tv/vyla/app/ui/components/SearchIcons.kt b/android/app/src/main/java/tv/vyla/app/ui/components/SearchIcons.kt new file mode 100644 index 0000000..4f155fe --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/ui/components/SearchIcons.kt @@ -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() + } +} \ No newline at end of file diff --git a/android/app/src/main/java/tv/vyla/app/ui/components/Sidebar.kt b/android/app/src/main/java/tv/vyla/app/ui/components/Sidebar.kt index 3821051..8499194 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/components/Sidebar.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/components/Sidebar.kt @@ -36,17 +36,27 @@ 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), WATCH_LATER("wl", "Watch Later", Icons.Default.Schedule), FAVORITES("fav", "Favorites", Icons.Default.Favorite) } @Composable +@OptIn(ExperimentalComposeUiApi::class) fun Sidebar( currentDestination: NavDestination, hasWatchLater: Boolean, @@ -57,102 +67,112 @@ fun Sidebar( ) { var focusedDestination by remember { mutableStateOf(null) } val isSidebarFocused = focusedDestination != null + val destinationFocusRequesters = remember { mutableMapOf() } 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.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 +182,7 @@ private fun SidebarItem( destination: NavDestination, isSelected: Boolean, isExpanded: Boolean, + focusRequester: FocusRequester? = null, onFocusGained: () -> Unit, onFocusLost: () -> Unit, onSelect: () -> Unit, @@ -169,38 +190,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 +228,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) ) } } -} +} \ No newline at end of file diff --git a/android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt b/android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt new file mode 100644 index 0000000..1d5fbd8 --- /dev/null +++ b/android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt @@ -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) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt index 3f58095..8083479 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/BrowseScreen.kt @@ -1,106 +1,62 @@ 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 data class GenreRow( @@ -112,8 +68,9 @@ data class GenreRow( val items: List ) -@Composable +@Composable +@OptIn(ExperimentalComposeUiApi::class) fun BrowseScreen( mode: NavDestination, onOpenDetail: (MediaItem) -> Unit, @@ -131,6 +88,11 @@ fun BrowseScreen( val movieGenres = remember { mutableStateListOf() } val tvGenres = remember { mutableStateListOf() } val playButtonFocusRequester = remember { FocusRequester() } + val currentMode by rememberUpdatedState(mode) + var loadGeneration by remember { mutableIntStateOf(0) } + val listState = rememberLazyListState() + val cardRequesters = remember { mutableMapOf() } +var lastFocusedKey by remember { mutableStateOf(null) } fun updateHero(item: MediaItem) { heroJob?.cancel() heroJob = coroutineScope.launch { @@ -139,26 +101,49 @@ fun BrowseScreen( 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) { + +suspend fun loadMoreRows(count: Int) { + if (isLoadingMore) 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 @@ -169,20 +154,66 @@ 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() + 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)) { Crossfade( targetState = heroItem?.ambientBackdropUrl, @@ -222,6 +253,7 @@ fun BrowseScreen( logoUrl = heroLogoUrl, playButtonFocusRequester = playButtonFocusRequester, onFocusedTop = { + lastFocusedKey = null coroutineScope.launch { listState.animateScrollToItem(0) } @@ -237,6 +269,8 @@ fun BrowseScreen( ) } items(rows) { row -> + val rowState = rememberLazyListState() + val rowIndex = rows.indexOf(row) Column(modifier = Modifier.padding(vertical = 10.dp)) { Text( text = row.title, @@ -246,17 +280,36 @@ 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 index = row.items.indexOf(item) val isFirstInRow = row.items.firstOrNull() == item + val cardKey = "${row.title}:${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) + ) + 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 @@ -440,14 +493,10 @@ 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( @@ -457,13 +506,8 @@ private fun HeroPillButton( .scale(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 { diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt index 84584f7..c6d4ff8 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/DetailScreen.kt @@ -132,14 +132,16 @@ 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 + seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, initialSeason.seasonNumber) } delay(150) try { @@ -150,6 +152,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 { @@ -387,6 +396,26 @@ fun DetailScreen( } if (hasEpisodes) { item { + val seasonsList = d?.seasons ?: emptyList() + val seasonChipFocusRequesters = remember(d) { + mutableMapOf() + } + 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 +443,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 +481,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 +804,12 @@ private fun SeasonDropdownButton( ) } } + @Composable private fun SeasonSelectChip( season: Season, isSelected: Boolean, + focusRequester: FocusRequester? = null, onFocused: (() -> Unit)? = null, onSelect: () -> Unit ) { @@ -782,15 +818,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 +852,11 @@ private fun SeasonSelectChip( ) } } + @Composable private fun DetailEpisodeCard( episode: Episode, + focusRequester: FocusRequester? = null, onFocused: (() -> Unit)? = null, onClick: () -> Unit ) { @@ -828,6 +866,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,6 +945,7 @@ private fun DetailEpisodeCard( ) } } + @Composable private fun DetailTrailerCard( trailer: VideoClip, @@ -914,7 +954,7 @@ private fun DetailTrailerCard( ) { var isFocused by remember { mutableStateOf(false) } val scale by animateFloatAsState(if (isFocused) 1.05f else 1.0f, label = "tr_scale") - Column( + Box( modifier = Modifier .width(280.dp) .scale(scale) @@ -930,53 +970,97 @@ private fun DetailTrailerCard( interactionSource = remember { MutableInteractionSource() }, indication = null ) { onClick() } - .padding(bottom = 14.dp) ) { - 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() - ) + Column { Box( modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(Color(0x88000000)) - .align(Alignment.Center), - contentAlignment = Alignment.Center + .fillMaxWidth() + .aspectRatio(16f / 9f) ) { - 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) + ) + } + 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 private fun DetailCastPortrait( member: CastMember, diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/LibraryScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/LibraryScreen.kt index b7e5bc7..1e2c925 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/screens/LibraryScreen.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/LibraryScreen.kt @@ -17,6 +17,16 @@ 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 @Composable fun LibraryScreen( @@ -25,10 +35,38 @@ fun LibraryScreen( onOpenDetail: (MediaItem) -> Unit, modifier: Modifier = Modifier ) { - Column( - modifier = modifier + var ambientUrl by remember(items) { mutableStateOf(items.firstOrNull()?.ambientBackdropUrl) } + Box(modifier = modifier.fillMaxSize().background(VylaBackground)) { + Crossfade( + targetState = ambientUrl, + animationSpec = tween(700), + label = "library_ambient" + ) { url -> + if (!url.isNullOrEmpty()) { + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .blur(50.dp) + ) + } + } + 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( @@ -75,10 +113,12 @@ fun LibraryScreen( MediaCard( item = item, width = 140, - onSelect = { onOpenDetail(item) } + onSelect = { onOpenDetail(item) }, + onFocused = { ambientUrl = it.ambientBackdropUrl } ) } } } } -} + } +} \ No newline at end of file diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt index 95ffcb7..b78787d 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/PlayerScreen.kt @@ -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() } - var activeSegment by remember { mutableStateOf(null) } + val segments = remember { mutableStateListOf() } + var activeSegment by remember { mutableStateOf(null) } + val skipButtonFocusRequester = remember { FocusRequester() } + val nextButtonFocusRequester = remember { FocusRequester() } + var playerViewRef by remember { mutableStateOf(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() } @@ -244,9 +184,8 @@ fun PlayerScreen( 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 + val endSec = s.end + currentSec >= s.start && (endSec == null || currentSec < endSec) } activeSegment = seg delay(300) @@ -276,11 +215,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 +275,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 +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) { showControlsAndFocusPlay() } + BackHandler { when { 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( modifier = modifier .fillMaxSize() @@ -409,6 +421,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 +496,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 +524,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 +533,7 @@ fun PlayerScreen( text = "Next Episode", icon = Icons.Default.SkipNext, isPrimary = true, + focusRequester = nextButtonFocusRequester, onClick = { playNextEpisode() resetHideTimer() @@ -725,7 +745,13 @@ fun PlayerScreen( }, onUpPress = { try { - moreButtonFocusRequester.requestFocus() + if (seg != null) { + skipButtonFocusRequester.requestFocus() + } else if (showNextPrompt) { + nextButtonFocusRequester.requestFocus() + } else { + moreButtonFocusRequester.requestFocus() + } } catch (_: Exception) {} } ) @@ -1024,7 +1050,7 @@ fun PlayerScreen( season = season, isSelected = selectedSeasonNum == season.seasonNumber, onSelect = { - loadSeasonEpisodes(season.seasonNumber) + loadSeasonEpisodes(season.seasonNumber, true) } ) } @@ -1044,16 +1070,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 +1112,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 +1151,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 +1198,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 +1217,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 +1227,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 +1239,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 +1487,7 @@ private fun MiniSelectButton( private fun DrawerEpisodeCard( episode: Episode, isCurrentlyPlaying: Boolean, + focusRequester: FocusRequester? = null, onClick: () -> Unit ) { var isFocused by remember { mutableStateOf(false) } @@ -1478,6 +1496,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 +1512,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 +1557,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 +1601,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( diff --git a/android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt b/android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt index 94c1e1c..d38d6ba 100644 --- a/android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt +++ b/android/app/src/main/java/tv/vyla/app/ui/screens/SearchScreen.kt @@ -1,96 +1,53 @@ 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 + @Composable fun SearchScreen( @@ -214,7 +171,7 @@ fun SearchScreen( .height(64.dp) ) { Icon( - imageVector = Icons.Default.Search, + imageVector = SearchIcons.Search, contentDescription = null, tint = Color(0xD9FFFFFF), modifier = Modifier.size(38.dp) @@ -242,6 +199,7 @@ fun SearchScreen( } } item { + val keyboardState = rememberLazyListState() Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -256,37 +214,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 +304,9 @@ fun SearchScreen( query = name performSearch(query) coroutineScope.launch { listState.animateScrollToItem(0) } + }, + onFocus = { + coroutineScope.launch { listState.animateScrollToItem(0) } } ) } @@ -328,18 +314,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 +416,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 +443,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 +462,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 +483,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 +496,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 +509,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 +527,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 +536,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 +553,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