mirror of
https://gitlab.com/vyla-entertainment/TV.git
synced 2026-11-13 11:37:21 -05:00
great UI update
This commit is contained in:
@@ -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,
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// im being salty
|
||||
|
||||
import { USER_AGENT, fetchJson } from '../utils/helpers.js';
|
||||
|
||||
const C_ARRAY = [
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
84
android/app/src/main/java/tv/vyla/app/api/IntroDbV3.kt
Normal file
84
android/app/src/main/java/tv/vyla/app/api/IntroDbV3.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
187
android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt
Normal file
187
android/app/src/main/java/tv/vyla/app/ui/components/VideoTint.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user