great UI update

This commit is contained in:
2026-10-05 13:21:01 -07:00
parent 112d405e75
commit 102c3bdc84
15 changed files with 992 additions and 536 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 = [
{
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,
},
{

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) {
const key = tmdbApiKey;
if (!key) return null;

View File

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

View File

@@ -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 },

View File

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

View File

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

View File

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

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.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<NavDestination?>(null) }
val isSidebarFocused = focusedDestination != null
val destinationFocusRequesters = remember { mutableMapOf<NavDestination, FocusRequester>() }
val animatedWidth by animateDpAsState(
targetValue = if (isSidebarFocused) 260.dp else 68.dp,
animationSpec = tween(durationMillis = 280),
targetValue = if (isSidebarFocused) 220.dp else 58.dp,
animationSpec = tween(durationMillis = 220),
label = "sidebar_width"
)
LaunchedEffect(Unit) {
delay(60)
try {
destinationFocusRequesters[currentDestination]?.requestFocus()
} catch (_: Exception) {}
}
Box(
modifier = modifier
.fillMaxHeight()
.width(animatedWidth)
.zIndex(10f)
.focusProperties {
enter = { destinationFocusRequesters[currentDestination] ?: FocusRequester.Default }
}
.focusGroup()
.background(
Brush.horizontalGradient(
colors = if (isSidebarFocused) {
listOf(Color(0xF0000000), Color(0xB3000000), Color.Transparent)
listOf(Color(0xF207070A), Color(0xCC07070A), Color.Transparent)
} else {
listOf(Color(0xCC000000), Color.Transparent)
listOf(Color(0xD907070A), Color.Transparent)
}
)
)
.padding(vertical = 24.dp)
.padding(vertical = 20.dp)
) {
Column(
modifier = Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = 14.dp, top = 2.dp)
.height(36.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.height(44.dp)
.size(30.dp)
.clip(CircleShape)
.background(Color.White),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.White),
contentAlignment = Alignment.Center
) {
androidx.compose.foundation.Image(
painter = painterResource(R.drawable.icon),
contentDescription = "Vyla Logo",
modifier = Modifier.size(32.dp),
contentScale = ContentScale.Fit
)
androidx.compose.foundation.Image(
painter = painterResource(R.drawable.icon),
contentDescription = "Vyla Logo",
modifier = Modifier.size(24.dp),
contentScale = ContentScale.Fit
)
}
AnimatedVisibility(
visible = isSidebarFocused,
enter = fadeIn(tween(160)),
exit = fadeOut(tween(100))
) {
Text(
text = "Vyla",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.5).sp,
modifier = Modifier.padding(start = 12.dp)
)
}
}
Column(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
NavDestination.values().forEach { destination ->
val shouldShow = when (destination) {
NavDestination.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)
)
}
}
}
}

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
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<MediaItem>
)
@Composable
@Composable
@OptIn(ExperimentalComposeUiApi::class)
fun BrowseScreen(
mode: NavDestination,
onOpenDetail: (MediaItem) -> Unit,
@@ -131,6 +88,11 @@ fun BrowseScreen(
val movieGenres = remember { mutableStateListOf<Genre>() }
val tvGenres = remember { mutableStateListOf<Genre>() }
val playButtonFocusRequester = remember { FocusRequester() }
val currentMode by rememberUpdatedState(mode)
var loadGeneration by remember { mutableIntStateOf(0) }
val listState = rememberLazyListState()
val cardRequesters = remember { mutableMapOf<String, FocusRequester>() }
var lastFocusedKey by remember { mutableStateOf<String?>(null) }
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 {

View File

@@ -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<Int, FocusRequester>()
}
val seasonRowState = rememberLazyListState()
LaunchedEffect(isSeasonDropdownOpen) {
if (isSeasonDropdownOpen) {
val targetIndex = seasonsList.indexOfFirst { it.seasonNumber == selectedSeason?.seasonNumber }
if (targetIndex >= 0) {
seasonRowState.scrollToItem(targetIndex)
delay(60)
try {
val targetSeason = seasonsList[targetIndex]
seasonChipFocusRequesters[targetSeason.seasonNumber]?.requestFocus()
} catch (_: Exception) {}
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -414,19 +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,

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.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 }
)
}
}
}
}
}
}
}

View File

@@ -1,150 +1,84 @@
package tv.vyla.app.ui.screens
import android.content.Context
import android.media.AudioManager
import android.view.KeyEvent
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeMute
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.media3.common.C
import androidx.media3.common.MediaItem as ExoMediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import coil.compose.AsyncImage
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import tv.vyla.app.api.IntroDbApi
import tv.vyla.app.api.IntroDbV3
import tv.vyla.app.api.SkipSegment
import tv.vyla.app.api.TmdbApi
import tv.vyla.app.data.LibraryManager
import tv.vyla.app.model.*
import tv.vyla.app.proxy.LocalProxy
import tv.vyla.app.source.VylaResolver
import tv.vyla.app.ui.theme.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import tv.vyla.app.ui.components.VideoTintSampler
import tv.vyla.app.ui.components.videoTint
enum class ActivePlayerMenu {
NONE, QUALITY, SUBTITLES, VOLUME, SETTINGS, MORE
@@ -175,8 +109,11 @@ fun PlayerScreen(
var currentPositionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
var bufferedPositionMs by remember { mutableLongStateOf(0L) }
val segments = remember { mutableStateListOf<MediaSegment>() }
var activeSegment by remember { mutableStateOf<MediaSegment?>(null) }
val segments = remember { mutableStateListOf<SkipSegment>() }
var activeSegment by remember { mutableStateOf<SkipSegment?>(null) }
val skipButtonFocusRequester = remember { FocusRequester() }
val nextButtonFocusRequester = remember { FocusRequester() }
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
var areControlsVisible by remember { mutableStateOf(true) }
var activeMenu by remember { mutableStateOf(ActivePlayerMenu.NONE) }
var isEpisodesDrawerOpen by remember { mutableStateOf(false) }
@@ -190,6 +127,9 @@ fun PlayerScreen(
val seekbarFocusRequester = remember { FocusRequester() }
val menuFirstItemFocusRequester = remember { FocusRequester() }
val episodesButtonFocusRequester = remember { FocusRequester() }
val episodeFocusRequester = remember { FocusRequester() }
val episodesListState = rememberLazyListState()
var episodeFocusIndex by remember { mutableIntStateOf(0) }
val qualityButtonFocusRequester = remember { FocusRequester() }
val ccButtonFocusRequester = remember { FocusRequester() }
val volButtonFocusRequester = remember { FocusRequester() }
@@ -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(

View File

@@ -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