mirror of
https://gitlab.com/vyla-entertainment/TV.git
synced 2026-11-14 20:17:22 -05:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 44a6e3cef9 | |||
| 9c0c5bf17d | |||
| 01e532a6a5 | |||
| 58c7889b8a | |||
| 102c3bdc84 | |||
| 112d405e75 | |||
| c3358fee44 |
5
android/.gitignore
vendored
5
android/.gitignore
vendored
@@ -89,13 +89,8 @@ lint/tmp/
|
|||||||
# Android Profiling
|
# Android Profiling
|
||||||
*.hprof
|
*.hprof
|
||||||
|
|
||||||
# Cordova plugins for Capacitor
|
|
||||||
capacitor-cordova-android-plugins
|
|
||||||
|
|
||||||
# Copied web assets
|
# Copied web assets
|
||||||
app/src/main/assets/public
|
app/src/main/assets/public
|
||||||
|
|
||||||
# Generated Config files
|
# Generated Config files
|
||||||
app/src/main/assets/capacitor.config.json
|
|
||||||
app/src/main/assets/capacitor.plugins.json
|
|
||||||
app/src/main/res/xml/config.xml
|
app/src/main/res/xml/config.xml
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
|
||||||
dependencies {
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (hasProperty('postBuildExtras')) {
|
|
||||||
postBuildExtras()
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package com.getcapacitor.myapp;
|
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry;
|
|
||||||
import org.junit.Test;
|
|
||||||
import org.junit.runner.RunWith;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instrumented test, which will execute on an Android device.
|
|
||||||
*
|
|
||||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4.class)
|
|
||||||
public class ExampleInstrumentedTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void useAppContext() throws Exception {
|
|
||||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
|
||||||
|
|
||||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,7 @@ import tv.vyla.app.ui.components.NavDestination
|
|||||||
import tv.vyla.app.ui.components.Sidebar
|
import tv.vyla.app.ui.components.Sidebar
|
||||||
import tv.vyla.app.ui.screens.*
|
import tv.vyla.app.ui.screens.*
|
||||||
import tv.vyla.app.ui.theme.VylaTvTheme
|
import tv.vyla.app.ui.theme.VylaTvTheme
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
val contentFocusRequester = remember { FocusRequester() }
|
val contentFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
BackHandler(enabled = playingItem == null && activeDetailItem == null) {
|
BackHandler(enabled = playingItem == null && activeDetailItem == null) {
|
||||||
if (currentDestination != NavDestination.DISCOVERY) {
|
if (currentDestination != NavDestination.DISCOVERY && currentDestination != NavDestination.ANIME) {
|
||||||
currentDestination = NavDestination.DISCOVERY
|
currentDestination = NavDestination.DISCOVERY
|
||||||
} else {
|
} else {
|
||||||
finish()
|
finish()
|
||||||
@@ -68,6 +69,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
onNavigate = { dest ->
|
onNavigate = { dest ->
|
||||||
currentDestination = dest
|
currentDestination = dest
|
||||||
activeDetailItem = null
|
activeDetailItem = null
|
||||||
|
contentFocusRequester.requestFocus()
|
||||||
},
|
},
|
||||||
onRequestExitSidebar = {
|
onRequestExitSidebar = {
|
||||||
try {
|
try {
|
||||||
@@ -80,15 +82,18 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val contentCovered = playingItem != null || activeDetailItem != null
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.focusRequester(contentFocusRequester)
|
.focusRequester(contentFocusRequester)
|
||||||
|
.drawWithContent { if (!contentCovered) drawContent() }
|
||||||
) {
|
) {
|
||||||
when (currentDestination) {
|
when (currentDestination) {
|
||||||
NavDestination.DISCOVERY,
|
NavDestination.DISCOVERY,
|
||||||
NavDestination.MOVIES,
|
NavDestination.MOVIES,
|
||||||
NavDestination.TV_SHOWS -> {
|
NavDestination.TV_SHOWS,
|
||||||
|
NavDestination.ANIME -> {
|
||||||
BrowseScreen(
|
BrowseScreen(
|
||||||
mode = currentDestination,
|
mode = currentDestination,
|
||||||
onOpenDetail = { item -> activeDetailItem = item },
|
onOpenDetail = { item -> activeDetailItem = item },
|
||||||
|
|||||||
@@ -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)
|
parseMediaList(json.optJSONArray("results"), mediaType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun getAnimeDiscover(genreId: Int, page: Int): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||||
|
val path = "/discover/tv?with_genres=$genreId&with_original_language=ja&sort_by=popularity.desc&vote_count.gte=50&include_adult=false&page=$page"
|
||||||
|
val json = fetchJson(path) ?: return@withContext emptyList()
|
||||||
|
parseMediaList(json.optJSONArray("results"), "tv")
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getTrending(mediaType: String): List<MediaItem> = withContext(Dispatchers.IO) {
|
suspend fun getTrending(mediaType: String): List<MediaItem> = withContext(Dispatchers.IO) {
|
||||||
val json = fetchJson("/trending/$mediaType/week") ?: return@withContext emptyList()
|
val json = fetchJson("/trending/$mediaType/week") ?: return@withContext emptyList()
|
||||||
parseMediaList(json.optJSONArray("results"), mediaType)
|
parseMediaList(json.optJSONArray("results"), mediaType)
|
||||||
@@ -302,7 +308,14 @@ object TmdbApi {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val CACHE_TTL_MS = 10 * 60 * 1000L
|
||||||
|
private val responseCache = android.util.LruCache<String, Pair<Long, JSONObject>>(80)
|
||||||
|
|
||||||
private suspend fun fetchJson(path: String): JSONObject? = withContext(Dispatchers.IO) {
|
private suspend fun fetchJson(path: String): JSONObject? = withContext(Dispatchers.IO) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
responseCache.get(path)?.let { (at, cached) ->
|
||||||
|
if (now - at < CACHE_TTL_MS) return@withContext cached
|
||||||
|
}
|
||||||
val separator = if (path.contains("?")) "&" else "?"
|
val separator = if (path.contains("?")) "&" else "?"
|
||||||
val url = "$BASE_URL$path${separator}api_key=$API_KEY&language=en-US"
|
val url = "$BASE_URL$path${separator}api_key=$API_KEY&language=en-US"
|
||||||
val req = Request.Builder().url(url).build()
|
val req = Request.Builder().url(url).build()
|
||||||
@@ -310,10 +323,12 @@ object TmdbApi {
|
|||||||
client.newCall(req).execute().use { resp ->
|
client.newCall(req).execute().use { resp ->
|
||||||
if (resp.isSuccessful) {
|
if (resp.isSuccessful) {
|
||||||
val body = resp.body?.string()
|
val body = resp.body?.string()
|
||||||
if (!body.isNullOrEmpty()) JSONObject(body) else null
|
if (!body.isNullOrEmpty()) {
|
||||||
|
JSONObject(body).also { responseCache.put(path, now to it) }
|
||||||
|
} else null
|
||||||
} else null
|
} else null
|
||||||
}
|
}
|
||||||
} catch (_: IOException) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import java.io.InputStream
|
|||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
class LocalProxy(port: Int = PORT) : NanoHTTPD("127.0.0.1", port) {
|
class LocalProxy(port: Int = PORT) : NanoHTTPD("127.0.0.1", port) {
|
||||||
|
|
||||||
@@ -73,6 +75,28 @@ class LocalProxy(port: Int = PORT) : NanoHTTPD("127.0.0.1", port) {
|
|||||||
override fun getDescription(): String = "$code Status"
|
override fun getDescription(): String = "$code Status"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class PoolRunner : AsyncRunner {
|
||||||
|
private val pool = Executors.newCachedThreadPool()
|
||||||
|
private val running = Collections.synchronizedList(ArrayList<ClientHandler>())
|
||||||
|
|
||||||
|
override fun closeAll() {
|
||||||
|
synchronized(running) { ArrayList(running).forEach { it.close() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun closed(clientHandler: ClientHandler) {
|
||||||
|
running.remove(clientHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun exec(clientHandler: ClientHandler) {
|
||||||
|
running.add(clientHandler)
|
||||||
|
pool.execute(clientHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
setAsyncRunner(PoolRunner())
|
||||||
|
}
|
||||||
|
|
||||||
override fun serve(session: IHTTPSession): Response {
|
override fun serve(session: IHTTPSession): Response {
|
||||||
val r: Response = when {
|
val r: Response = when {
|
||||||
session.method == Method.OPTIONS -> newFixedLengthResponse(Code(204), "text/plain", "")
|
session.method == Method.OPTIONS -> newFixedLengthResponse(Code(204), "text/plain", "")
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package tv.vyla.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.CompositingStrategy
|
||||||
|
import androidx.compose.ui.graphics.FilterQuality
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
|
||||||
|
val HeroFadeStops: Array<Pair<Float, Float>> = arrayOf(
|
||||||
|
0f to 1f,
|
||||||
|
0.38f to 1f,
|
||||||
|
0.50f to 0.92f,
|
||||||
|
0.62f to 0.70f,
|
||||||
|
0.74f to 0.42f,
|
||||||
|
0.86f to 0.16f,
|
||||||
|
1f to 0f
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AmbientBackdrop(url: String?, modifier: Modifier = Modifier, durationMs: Int = 600) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Crossfade(
|
||||||
|
targetState = url,
|
||||||
|
modifier = modifier,
|
||||||
|
animationSpec = tween(durationMs),
|
||||||
|
label = "ambient_backdrop"
|
||||||
|
) { target ->
|
||||||
|
if (!target.isNullOrEmpty()) {
|
||||||
|
val request = remember(target) {
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(target)
|
||||||
|
.size(48, 27)
|
||||||
|
.allowHardware(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
AsyncImage(
|
||||||
|
model = request,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
filterQuality = FilterQuality.Medium,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Modifier.verticalFadeMask(vararg stops: Pair<Float, Float>): Modifier =
|
||||||
|
this
|
||||||
|
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
||||||
|
.drawWithCache {
|
||||||
|
val brush = Brush.verticalGradient(
|
||||||
|
*Array(stops.size) { stops[it].first to Color.Black.copy(alpha = stops[it].second) }
|
||||||
|
)
|
||||||
|
onDrawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(brush = brush, blendMode = BlendMode.DstIn)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import coil.compose.AsyncImage
|
|||||||
import tv.vyla.app.model.MediaItem
|
import tv.vyla.app.model.MediaItem
|
||||||
import tv.vyla.app.ui.theme.VylaCardBackground
|
import tv.vyla.app.ui.theme.VylaCardBackground
|
||||||
import tv.vyla.app.ui.theme.VylaTextPrimary
|
import tv.vyla.app.ui.theme.VylaTextPrimary
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaCard(
|
fun MediaCard(
|
||||||
@@ -48,7 +49,10 @@ fun MediaCard(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(width.dp)
|
.width(width.dp)
|
||||||
.aspectRatio(2f / 3f)
|
.aspectRatio(2f / 3f)
|
||||||
.scale(scale)
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
.clip(shape)
|
.clip(shape)
|
||||||
.background(VylaCardBackground)
|
.background(VylaCardBackground)
|
||||||
.then(
|
.then(
|
||||||
|
|||||||
@@ -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.R
|
||||||
import tv.vyla.app.ui.theme.VylaTextMuted
|
import tv.vyla.app.ui.theme.VylaTextMuted
|
||||||
import tv.vyla.app.ui.theme.VylaTextPrimary
|
import tv.vyla.app.ui.theme.VylaTextPrimary
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.focus.focusProperties
|
||||||
|
|
||||||
|
object ContentFocusBridge {
|
||||||
|
var restore: (() -> Boolean)? = null
|
||||||
|
}
|
||||||
|
|
||||||
enum class NavDestination(val key: String, val title: String, val icon: ImageVector) {
|
enum class NavDestination(val key: String, val title: String, val icon: ImageVector) {
|
||||||
SEARCH("search", "Search", Icons.Default.Search),
|
SEARCH("search", "Search", Icons.Default.Search),
|
||||||
DISCOVERY("discover", "Discovery", Icons.Default.Explore),
|
DISCOVERY("discover", "Discovery", Icons.Default.Explore),
|
||||||
MOVIES("movie", "Movies", Icons.Default.Movie),
|
MOVIES("movie", "Movies", Icons.Default.Movie),
|
||||||
TV_SHOWS("tv", "TV Shows", Icons.Default.Tv),
|
TV_SHOWS("tv", "TV Shows", Icons.Default.Tv),
|
||||||
|
ANIME("anime", "Anime", Icons.Default.AutoAwesome),
|
||||||
WATCH_LATER("wl", "Watch Later", Icons.Default.Schedule),
|
WATCH_LATER("wl", "Watch Later", Icons.Default.Schedule),
|
||||||
FAVORITES("fav", "Favorites", Icons.Default.Favorite)
|
FAVORITES("fav", "Favorites", Icons.Default.Favorite)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun Sidebar(
|
fun Sidebar(
|
||||||
currentDestination: NavDestination,
|
currentDestination: NavDestination,
|
||||||
hasWatchLater: Boolean,
|
hasWatchLater: Boolean,
|
||||||
@@ -57,102 +67,112 @@ fun Sidebar(
|
|||||||
) {
|
) {
|
||||||
var focusedDestination by remember { mutableStateOf<NavDestination?>(null) }
|
var focusedDestination by remember { mutableStateOf<NavDestination?>(null) }
|
||||||
val isSidebarFocused = focusedDestination != null
|
val isSidebarFocused = focusedDestination != null
|
||||||
|
val destinationFocusRequesters = remember { mutableMapOf<NavDestination, FocusRequester>() }
|
||||||
|
|
||||||
val animatedWidth by animateDpAsState(
|
val animatedWidth by animateDpAsState(
|
||||||
targetValue = if (isSidebarFocused) 260.dp else 68.dp,
|
targetValue = if (isSidebarFocused) 220.dp else 58.dp,
|
||||||
animationSpec = tween(durationMillis = 280),
|
animationSpec = tween(durationMillis = 220),
|
||||||
label = "sidebar_width"
|
label = "sidebar_width"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(60)
|
||||||
|
try {
|
||||||
|
destinationFocusRequesters[currentDestination]?.requestFocus()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.width(animatedWidth)
|
.width(animatedWidth)
|
||||||
.zIndex(10f)
|
.zIndex(10f)
|
||||||
|
.focusProperties {
|
||||||
|
enter = { destinationFocusRequesters[currentDestination] ?: FocusRequester.Default }
|
||||||
|
}
|
||||||
|
.focusGroup()
|
||||||
.background(
|
.background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
colors = if (isSidebarFocused) {
|
colors = if (isSidebarFocused) {
|
||||||
listOf(Color(0xF0000000), Color(0xB3000000), Color.Transparent)
|
listOf(Color(0xF207070A), Color(0xCC07070A), Color.Transparent)
|
||||||
} else {
|
} else {
|
||||||
listOf(Color(0xCC000000), Color.Transparent)
|
listOf(Color(0xD907070A), Color.Transparent)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.padding(vertical = 24.dp)
|
.padding(vertical = 20.dp)
|
||||||
) {
|
) {
|
||||||
Column(
|
Row(
|
||||||
modifier = Modifier.fillMaxHeight(),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
verticalArrangement = Arrangement.SpaceBetween
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(start = 14.dp, top = 2.dp)
|
||||||
|
.height(36.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Box(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.size(30.dp)
|
||||||
.height(44.dp)
|
.clip(CircleShape)
|
||||||
|
.background(Color.White),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Box(
|
androidx.compose.foundation.Image(
|
||||||
modifier = Modifier
|
painter = painterResource(R.drawable.icon),
|
||||||
.size(36.dp)
|
contentDescription = "Vyla Logo",
|
||||||
.clip(CircleShape)
|
modifier = Modifier.size(24.dp),
|
||||||
.background(Color.White),
|
contentScale = ContentScale.Fit
|
||||||
contentAlignment = Alignment.Center
|
)
|
||||||
) {
|
}
|
||||||
androidx.compose.foundation.Image(
|
|
||||||
painter = painterResource(R.drawable.icon),
|
AnimatedVisibility(
|
||||||
contentDescription = "Vyla Logo",
|
visible = isSidebarFocused,
|
||||||
modifier = Modifier.size(32.dp),
|
enter = fadeIn(tween(160)),
|
||||||
contentScale = ContentScale.Fit
|
exit = fadeOut(tween(100))
|
||||||
)
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Vyla",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = (-0.5).sp,
|
||||||
|
modifier = Modifier.padding(start = 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
NavDestination.values().forEach { destination ->
|
||||||
|
val shouldShow = when (destination) {
|
||||||
|
NavDestination.WATCH_LATER -> hasWatchLater || currentDestination == NavDestination.WATCH_LATER
|
||||||
|
NavDestination.FAVORITES -> hasFavorites || currentDestination == NavDestination.FAVORITES
|
||||||
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
if (shouldShow) {
|
||||||
visible = isSidebarFocused,
|
val requester = destinationFocusRequesters.getOrPut(destination) { FocusRequester() }
|
||||||
enter = fadeIn(tween(200)),
|
SidebarItem(
|
||||||
exit = fadeOut(tween(150))
|
destination = destination,
|
||||||
) {
|
isSelected = currentDestination == destination,
|
||||||
Text(
|
isExpanded = isSidebarFocused,
|
||||||
text = "Vyla",
|
focusRequester = requester,
|
||||||
color = Color.White,
|
onFocusGained = { focusedDestination = destination },
|
||||||
fontSize = 22.sp,
|
onFocusLost = { if (focusedDestination == destination) focusedDestination = null },
|
||||||
fontWeight = FontWeight.Bold,
|
onSelect = {
|
||||||
letterSpacing = (-0.5).sp,
|
focusedDestination = null
|
||||||
modifier = Modifier.padding(start = 14.dp)
|
onNavigate(destination)
|
||||||
|
},
|
||||||
|
onRightPress = {
|
||||||
|
focusedDestination = null
|
||||||
|
if (ContentFocusBridge.restore?.invoke() != true) onRequestExitSidebar()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.padding(horizontal = 10.dp)
|
|
||||||
) {
|
|
||||||
NavDestination.values().forEach { destination ->
|
|
||||||
val shouldShow = when (destination) {
|
|
||||||
NavDestination.WATCH_LATER -> hasWatchLater || currentDestination == NavDestination.WATCH_LATER
|
|
||||||
NavDestination.FAVORITES -> hasFavorites || currentDestination == NavDestination.FAVORITES
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldShow) {
|
|
||||||
SidebarItem(
|
|
||||||
destination = destination,
|
|
||||||
isSelected = currentDestination == destination,
|
|
||||||
isExpanded = isSidebarFocused,
|
|
||||||
onFocusGained = { focusedDestination = destination },
|
|
||||||
onFocusLost = { if (focusedDestination == destination) focusedDestination = null },
|
|
||||||
onSelect = {
|
|
||||||
focusedDestination = null
|
|
||||||
onNavigate(destination)
|
|
||||||
},
|
|
||||||
onRightPress = {
|
|
||||||
focusedDestination = null
|
|
||||||
onRequestExitSidebar()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(40.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,6 +182,7 @@ private fun SidebarItem(
|
|||||||
destination: NavDestination,
|
destination: NavDestination,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
onFocusGained: () -> Unit,
|
onFocusGained: () -> Unit,
|
||||||
onFocusLost: () -> Unit,
|
onFocusLost: () -> Unit,
|
||||||
onSelect: () -> Unit,
|
onSelect: () -> Unit,
|
||||||
@@ -169,38 +190,37 @@ private fun SidebarItem(
|
|||||||
) {
|
) {
|
||||||
var isItemFocused by remember { mutableStateOf(false) }
|
var isItemFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val shape = RoundedCornerShape(26.dp)
|
|
||||||
val background = when {
|
val background = when {
|
||||||
isItemFocused -> Color.White
|
isItemFocused -> Color.White
|
||||||
isSelected && !isExpanded -> Color(0x33FFFFFF)
|
|
||||||
else -> Color.Transparent
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
val contentColor = when {
|
val contentColor = when {
|
||||||
isItemFocused -> Color.Black
|
isItemFocused -> Color.Black
|
||||||
isSelected -> Color.White
|
isSelected -> Color.White
|
||||||
else -> VylaTextMuted
|
else -> Color(0x99FFFFFF)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(52.dp)
|
.height(42.dp)
|
||||||
.clip(shape)
|
.clip(RoundedCornerShape(21.dp))
|
||||||
.background(background)
|
.background(background)
|
||||||
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
.onFocusChanged { state ->
|
.onFocusChanged { state ->
|
||||||
isItemFocused = state.isFocused
|
isItemFocused = state.isFocused
|
||||||
if (state.isFocused) onFocusGained() else onFocusLost()
|
if (state.isFocused) onFocusGained() else onFocusLost()
|
||||||
}
|
}
|
||||||
.onKeyEvent { event ->
|
.onKeyEvent { event ->
|
||||||
if (event.type == KeyEventType.KeyDown) {
|
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight) {
|
||||||
if (event.key == Key.DirectionRight) {
|
onRightPress()
|
||||||
onRightPress()
|
true
|
||||||
return@onKeyEvent true
|
} else {
|
||||||
}
|
false
|
||||||
}
|
}
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.focusable()
|
.focusable()
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
@@ -208,27 +228,31 @@ private fun SidebarItem(
|
|||||||
) {
|
) {
|
||||||
onSelect()
|
onSelect()
|
||||||
}
|
}
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Box(
|
||||||
imageVector = destination.icon,
|
modifier = Modifier.size(42.dp),
|
||||||
contentDescription = destination.title,
|
contentAlignment = Alignment.Center
|
||||||
tint = contentColor,
|
) {
|
||||||
modifier = Modifier.size(24.dp)
|
Icon(
|
||||||
)
|
imageVector = destination.icon,
|
||||||
|
contentDescription = destination.title,
|
||||||
|
tint = contentColor,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isExpanded,
|
visible = isExpanded,
|
||||||
enter = fadeIn(tween(200)),
|
enter = fadeIn(tween(160)),
|
||||||
exit = fadeOut(tween(150))
|
exit = fadeOut(tween(100))
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = destination.title,
|
text = destination.title,
|
||||||
color = contentColor,
|
color = contentColor,
|
||||||
fontSize = 17.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = if (isSelected || isItemFocused) FontWeight.Bold else FontWeight.Medium,
|
fontWeight = if (isSelected || isItemFocused) FontWeight.Bold else FontWeight.Medium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
modifier = Modifier.padding(start = 16.dp)
|
modifier = Modifier.padding(start = 2.dp, end = 14.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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(700)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun captureFrame(view: View, bitmap: Bitmap): Boolean {
|
||||||
|
return when (view) {
|
||||||
|
is SurfaceView -> {
|
||||||
|
if (Build.VERSION.SDK_INT < 24 || !view.holder.surface.isValid || view.width == 0 || view.height == 0) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
try {
|
||||||
|
PixelCopy.request(
|
||||||
|
view,
|
||||||
|
bitmap,
|
||||||
|
{ result -> if (cont.isActive) cont.resume(result == PixelCopy.SUCCESS) },
|
||||||
|
Handler(Looper.getMainLooper())
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (cont.isActive) cont.resume(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is TextureView -> view.isAvailable && view.getBitmap(bitmap) != null
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tinted(sample: Color, selected: Boolean): Color {
|
||||||
|
val hsv = FloatArray(3)
|
||||||
|
android.graphics.Color.colorToHSV(sample.toArgb(), hsv)
|
||||||
|
hsv[1] = (hsv[1] * 1.6f).coerceAtMost(1f)
|
||||||
|
hsv[2] = if (selected) {
|
||||||
|
(hsv[2] * 0.9f).coerceIn(0.3f, 0.85f)
|
||||||
|
} else {
|
||||||
|
(hsv[2] * 0.5f).coerceIn(0.05f, 0.45f)
|
||||||
|
}
|
||||||
|
return Color(android.graphics.Color.HSVToColor(hsv)).copy(alpha = if (selected) 0.9f else 0.8f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Modifier.videoTint(isFocused: Boolean, isSelected: Boolean = false): Modifier {
|
||||||
|
val frame = VideoTintState.frame
|
||||||
|
val rootView = LocalView.current.rootView
|
||||||
|
var center by remember { mutableStateOf(Offset.Unspecified) }
|
||||||
|
val target = when {
|
||||||
|
isFocused -> Color(0xF2FFFFFF)
|
||||||
|
frame == null || center == Offset.Unspecified || rootView.width == 0 || rootView.height == 0 ->
|
||||||
|
if (isSelected) Color(0xCC1A1A1A) else Color(0x99020202)
|
||||||
|
else -> tinted(
|
||||||
|
frame.colorAt(center.x / rootView.width, center.y / rootView.height),
|
||||||
|
isSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val animated by animateColorAsState(target, tween(350), label = "video_tint")
|
||||||
|
return this
|
||||||
|
.onGloballyPositioned { center = it.boundsInWindow().center }
|
||||||
|
.drawBehind { drawRect(animated) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BackdropTintSampler(url: String?) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { VideoTintState.frame = null }
|
||||||
|
}
|
||||||
|
LaunchedEffect(url) {
|
||||||
|
if (url.isNullOrEmpty()) {
|
||||||
|
VideoTintState.frame = null
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
val request = ImageRequest.Builder(context)
|
||||||
|
.data(url)
|
||||||
|
.size(48, 27)
|
||||||
|
.allowHardware(false)
|
||||||
|
.build()
|
||||||
|
val result = context.imageLoader.execute(request)
|
||||||
|
val source = ((result as? SuccessResult)?.drawable as? BitmapDrawable)?.bitmap
|
||||||
|
?: return@LaunchedEffect
|
||||||
|
val out = Bitmap.createBitmap(GRID_W, GRID_H, Bitmap.Config.ARGB_8888)
|
||||||
|
val targetRatio = GRID_W.toFloat() / GRID_H
|
||||||
|
val sourceRatio = source.width.toFloat() / source.height
|
||||||
|
val srcRect = if (sourceRatio > targetRatio) {
|
||||||
|
val w = (source.height * targetRatio).toInt()
|
||||||
|
Rect((source.width - w) / 2, 0, (source.width + w) / 2, source.height)
|
||||||
|
} else {
|
||||||
|
val h = (source.width / targetRatio).toInt()
|
||||||
|
Rect(0, (source.height - h) / 2, source.width, (source.height + h) / 2)
|
||||||
|
}
|
||||||
|
Canvas(out).drawBitmap(source, srcRect, Rect(0, 0, GRID_W, GRID_H), null)
|
||||||
|
val pixels = IntArray(GRID_W * GRID_H)
|
||||||
|
out.getPixels(pixels, 0, GRID_W, 0, 0, GRID_W, GRID_H)
|
||||||
|
VideoTintState.frame = VideoFrameColors(pixels, GRID_W, GRID_H)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,106 +1,66 @@
|
|||||||
package tv.vyla.app.ui.screens
|
package tv.vyla.app.ui.screens
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
import androidx.compose.ui.draw.blur
|
import androidx.compose.ui.draw.blur
|
||||||
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.CompositingStrategy
|
import androidx.compose.ui.graphics.CompositingStrategy
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
|
||||||
import androidx.compose.ui.input.key.*
|
import androidx.compose.ui.input.key.*
|
||||||
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
import tv.vyla.app.api.TmdbApi
|
import tv.vyla.app.api.TmdbApi
|
||||||
|
|
||||||
import tv.vyla.app.model.Genre
|
import tv.vyla.app.model.Genre
|
||||||
|
|
||||||
import tv.vyla.app.model.MediaItem
|
import tv.vyla.app.model.MediaItem
|
||||||
|
|
||||||
import tv.vyla.app.ui.components.MediaCard
|
import tv.vyla.app.ui.components.MediaCard
|
||||||
|
|
||||||
import tv.vyla.app.ui.components.NavDestination
|
import tv.vyla.app.ui.components.NavDestination
|
||||||
|
|
||||||
import tv.vyla.app.ui.theme.*
|
import tv.vyla.app.ui.theme.*
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import tv.vyla.app.ui.components.ContentFocusBridge
|
||||||
|
import tv.vyla.app.ui.components.BackdropTintSampler
|
||||||
|
import tv.vyla.app.ui.components.videoTint
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import tv.vyla.app.ui.components.AmbientBackdrop
|
||||||
|
import tv.vyla.app.ui.components.HeroFadeStops
|
||||||
|
import tv.vyla.app.ui.components.verticalFadeMask
|
||||||
|
|
||||||
data class GenreRow(
|
data class GenreRow(
|
||||||
|
|
||||||
@@ -112,8 +72,9 @@ data class GenreRow(
|
|||||||
|
|
||||||
val items: List<MediaItem>
|
val items: List<MediaItem>
|
||||||
)
|
)
|
||||||
@Composable
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun BrowseScreen(
|
fun BrowseScreen(
|
||||||
mode: NavDestination,
|
mode: NavDestination,
|
||||||
onOpenDetail: (MediaItem) -> Unit,
|
onOpenDetail: (MediaItem) -> Unit,
|
||||||
@@ -131,37 +92,73 @@ fun BrowseScreen(
|
|||||||
val movieGenres = remember { mutableStateListOf<Genre>() }
|
val movieGenres = remember { mutableStateListOf<Genre>() }
|
||||||
val tvGenres = remember { mutableStateListOf<Genre>() }
|
val tvGenres = remember { mutableStateListOf<Genre>() }
|
||||||
val playButtonFocusRequester = remember { FocusRequester() }
|
val playButtonFocusRequester = remember { FocusRequester() }
|
||||||
|
val currentMode by rememberUpdatedState(mode)
|
||||||
|
var loadGeneration by remember { mutableIntStateOf(0) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val cardRequesters = remember { mutableMapOf<String, FocusRequester>() }
|
||||||
|
var lastFocusedKey by remember { mutableStateOf<String?>(null) }
|
||||||
|
var ambientUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
var lastRowIndex by remember { mutableIntStateOf(-1) }
|
||||||
fun updateHero(item: MediaItem) {
|
fun updateHero(item: MediaItem) {
|
||||||
heroJob?.cancel()
|
heroJob?.cancel()
|
||||||
|
if (heroItem?.id == item.id && heroItem?.mediaType == item.mediaType) return
|
||||||
heroJob = coroutineScope.launch {
|
heroJob = coroutineScope.launch {
|
||||||
delay(130)
|
delay(200)
|
||||||
heroItem = item
|
heroItem = item
|
||||||
|
ambientUrl = item.ambientBackdropUrl
|
||||||
heroLogoUrl = TmdbApi.getLogo(item.mediaType, item.id)
|
heroLogoUrl = TmdbApi.getLogo(item.mediaType, item.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
suspend fun loadMoreRows(count: Int) {
|
fun pinAmbientToHero() {
|
||||||
if (isLoadingMore) return
|
heroItem?.let { ambientUrl = it.ambientBackdropUrl }
|
||||||
isLoadingMore = true
|
}
|
||||||
val isMovieMode = mode == NavDestination.MOVIES
|
|
||||||
val isTvMode = mode == NavDestination.TV_SHOWS
|
suspend fun loadMoreRows(count: Int) {
|
||||||
val isDiscover = mode == NavDestination.DISCOVERY
|
if (isLoadingMore || rows.size >= 48) return
|
||||||
for (i in 0 until count) {
|
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 curI = rowIdx++
|
||||||
val isMovie = isMovieMode || (isDiscover && curI % 2 == 0)
|
val isMovie = when {
|
||||||
|
isMovieMode -> true
|
||||||
|
isTvMode -> false
|
||||||
|
isAnimeMode -> false
|
||||||
|
isDiscover -> curI % 2 == 0
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
val list = if (isMovie) movieGenres else tvGenres
|
val list = if (isMovie) movieGenres else tvGenres
|
||||||
if (list.isEmpty()) continue
|
if (list.isEmpty()) continue
|
||||||
val genreIdx = if (isDiscover) curI / 2 else curI
|
val genreIdx = if (isDiscover) curI / 2 else curI
|
||||||
val genre = list[genreIdx % list.size]
|
val genre = list[genreIdx % list.size]
|
||||||
val page = 1 + (genreIdx / list.size) % 15
|
val page = 1 + (genreIdx / list.size) % 15
|
||||||
val mt = if (isMovie) "movie" else "tv"
|
val mt = if (isMovie) "movie" else "tv"
|
||||||
val items = TmdbApi.getDiscover(mt, genre.id, page)
|
val items = if (isAnimeMode) {
|
||||||
|
TmdbApi.getAnimeDiscover(genre.id, page)
|
||||||
|
} else {
|
||||||
|
TmdbApi.getDiscover(mt, genre.id, page)
|
||||||
|
}
|
||||||
|
if (generation != loadGeneration) return
|
||||||
if (items.isNotEmpty()) {
|
if (items.isNotEmpty()) {
|
||||||
val title = "${genre.name} ${if (isMovie) "Movies" else "TV Shows"}"
|
val title = when {
|
||||||
|
isAnimeMode -> "${genre.name} Anime"
|
||||||
|
else -> "${genre.name} ${if (isMovie) "Movies" else "TV Shows"}"
|
||||||
|
}
|
||||||
rows.add(GenreRow(title, mt, genre.id, items))
|
rows.add(GenreRow(title, mt, genre.id, items))
|
||||||
if (heroItem == null && rows.isNotEmpty()) {
|
added++
|
||||||
|
if (heroItem == null) {
|
||||||
val first = rows[0].items.firstOrNull()
|
val first = rows[0].items.firstOrNull()
|
||||||
if (first != null) {
|
if (first != null) {
|
||||||
heroItem = first
|
heroItem = first
|
||||||
|
ambientUrl = first.ambientBackdropUrl
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
heroLogoUrl = TmdbApi.getLogo(first.mediaType, first.id)
|
heroLogoUrl = TmdbApi.getLogo(first.mediaType, first.id)
|
||||||
}
|
}
|
||||||
@@ -169,37 +166,74 @@ fun BrowseScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isLoadingMore = false
|
} finally {
|
||||||
|
if (generation == loadGeneration) isLoadingMore = false
|
||||||
}
|
}
|
||||||
LaunchedEffect(mode) {
|
}
|
||||||
rows.clear()
|
|
||||||
rowIdx = 0
|
fun restoreContentFocus(): Boolean {
|
||||||
heroItem = null
|
val cardRequester = lastFocusedKey?.let { cardRequesters[it] }
|
||||||
heroLogoUrl = null
|
if (cardRequester != null) {
|
||||||
movieGenres.clear()
|
try {
|
||||||
tvGenres.clear()
|
cardRequester.requestFocus()
|
||||||
movieGenres.addAll(TmdbApi.getGenreList("movie"))
|
return true
|
||||||
tvGenres.addAll(TmdbApi.getGenreList("tv"))
|
} catch (_: Exception) {}
|
||||||
loadMoreRows(5)
|
|
||||||
}
|
}
|
||||||
val listState = rememberLazyListState()
|
return try {
|
||||||
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
|
playButtonFocusRequester.requestFocus()
|
||||||
Crossfade(
|
true
|
||||||
targetState = heroItem?.ambientBackdropUrl,
|
} catch (_: Exception) {
|
||||||
animationSpec = tween(700),
|
false
|
||||||
label = "ambient_bg"
|
}
|
||||||
) { url ->
|
}
|
||||||
if (!url.isNullOrEmpty()) {
|
|
||||||
AsyncImage(
|
DisposableEffect(Unit) {
|
||||||
model = url,
|
val restore: () -> Boolean = { restoreContentFocus() }
|
||||||
contentDescription = null,
|
ContentFocusBridge.restore = restore
|
||||||
contentScale = ContentScale.Crop,
|
onDispose {
|
||||||
modifier = Modifier
|
if (ContentFocusBridge.restore === restore) ContentFocusBridge.restore = null
|
||||||
.fillMaxSize()
|
}
|
||||||
.blur(50.dp)
|
}
|
||||||
)
|
|
||||||
}
|
LaunchedEffect(mode) {
|
||||||
|
loadGeneration++
|
||||||
|
isLoadingMore = false
|
||||||
|
lastFocusedKey = null
|
||||||
|
cardRequesters.clear()
|
||||||
|
rows.clear()
|
||||||
|
rowIdx = 0
|
||||||
|
heroItem = null
|
||||||
|
ambientUrl = null
|
||||||
|
lastRowIndex = -1
|
||||||
|
heroLogoUrl = null
|
||||||
|
movieGenres.clear()
|
||||||
|
tvGenres.clear()
|
||||||
|
listState.scrollToItem(0)
|
||||||
|
movieGenres.addAll(TmdbApi.getGenreList("movie"))
|
||||||
|
tvGenres.addAll(TmdbApi.getGenreList("tv"))
|
||||||
|
loadMoreRows(5)
|
||||||
|
withTimeoutOrNull(10000) { snapshotFlow { heroItem }.first { it != null } }
|
||||||
|
delay(150)
|
||||||
|
var tries = 0
|
||||||
|
var focused = false
|
||||||
|
while (!focused && tries < 10 && lastFocusedKey == null) {
|
||||||
|
tries++
|
||||||
|
try {
|
||||||
|
playButtonFocusRequester.requestFocus()
|
||||||
|
focused = true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
delay(100)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackdropTintSampler(heroItem?.ambientBackdropUrl)
|
||||||
|
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
|
||||||
|
AmbientBackdrop(
|
||||||
|
url = ambientUrl,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
durationMs = 700
|
||||||
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -222,6 +256,9 @@ fun BrowseScreen(
|
|||||||
logoUrl = heroLogoUrl,
|
logoUrl = heroLogoUrl,
|
||||||
playButtonFocusRequester = playButtonFocusRequester,
|
playButtonFocusRequester = playButtonFocusRequester,
|
||||||
onFocusedTop = {
|
onFocusedTop = {
|
||||||
|
lastFocusedKey = null
|
||||||
|
lastRowIndex = -1
|
||||||
|
pinAmbientToHero()
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
@@ -236,7 +273,8 @@ fun BrowseScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(rows) { row ->
|
itemsIndexed(rows, key = { i, row -> "$i:${row.title}" }) { rowIndex, row ->
|
||||||
|
val rowState = rememberLazyListState()
|
||||||
Column(modifier = Modifier.padding(vertical = 10.dp)) {
|
Column(modifier = Modifier.padding(vertical = 10.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = row.title,
|
text = row.title,
|
||||||
@@ -246,17 +284,40 @@ fun BrowseScreen(
|
|||||||
modifier = Modifier.padding(start = 78.dp, bottom = 12.dp)
|
modifier = Modifier.padding(start = 78.dp, bottom = 12.dp)
|
||||||
)
|
)
|
||||||
LazyRow(
|
LazyRow(
|
||||||
|
state = rowState,
|
||||||
contentPadding = PaddingValues(horizontal = 78.dp),
|
contentPadding = PaddingValues(horizontal = 78.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
items(row.items) { item ->
|
itemsIndexed(row.items, key = { _, it -> it.id }) { index, item ->
|
||||||
val isFirstInRow = row.items.firstOrNull() == item
|
val isFirstInRow = index == 0
|
||||||
|
val cardKey = "$rowIndex:${item.id}"
|
||||||
|
val cardRequester = cardRequesters.getOrPut(cardKey) { FocusRequester() }
|
||||||
MediaCard(
|
MediaCard(
|
||||||
item = item,
|
item = item,
|
||||||
width = 136,
|
width = 136,
|
||||||
onSelect = { onOpenDetail(item) },
|
onSelect = { onOpenDetail(item) },
|
||||||
onFocused = { updateHero(item) },
|
onFocused = {
|
||||||
modifier = Modifier.onKeyEvent { event ->
|
lastFocusedKey = cardKey
|
||||||
|
updateHero(item)
|
||||||
|
coroutineScope.launch {
|
||||||
|
val viewportWidth = rowState.layoutInfo.viewportEndOffset - rowState.layoutInfo.viewportStartOffset
|
||||||
|
rowState.animateScrollToItem(
|
||||||
|
index,
|
||||||
|
-(viewportWidth / 2 - 68)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (lastRowIndex != rowIndex) {
|
||||||
|
lastRowIndex = rowIndex
|
||||||
|
coroutineScope.launch {
|
||||||
|
val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset
|
||||||
|
listState.animateScrollToItem(
|
||||||
|
rowIndex + 1,
|
||||||
|
-(viewportHeight / 2 - 100)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.focusRequester(cardRequester).onKeyEvent { event ->
|
||||||
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionLeft && isFirstInRow) {
|
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionLeft && isFirstInRow) {
|
||||||
onRequestSidebar()
|
onRequestSidebar()
|
||||||
return@onKeyEvent true
|
return@onKeyEvent true
|
||||||
@@ -292,39 +353,32 @@ private fun HeroSection(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(460.dp)
|
.height(460.dp)
|
||||||
) {
|
) {
|
||||||
val backdropUrl = item?.backdropUrl
|
|
||||||
if (!backdropUrl.isNullOrEmpty()) {
|
|
||||||
AsyncImage(
|
|
||||||
model = backdropUrl,
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
|
||||||
.drawWithContent {
|
|
||||||
drawContent()
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
0.0f to Color.Black,
|
|
||||||
0.45f to Color.Black,
|
|
||||||
1.0f to Color.Transparent
|
|
||||||
),
|
|
||||||
blendMode = BlendMode.DstIn
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(
|
.verticalFadeMask(*HeroFadeStops)
|
||||||
Brush.horizontalGradient(
|
) {
|
||||||
0.0f to Color(0xCC000000),
|
val backdropUrl = item?.backdropUrl
|
||||||
0.40f to Color(0x59000000),
|
if (!backdropUrl.isNullOrEmpty()) {
|
||||||
0.70f to Color.Transparent
|
AsyncImage(
|
||||||
)
|
model = backdropUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
0.0f to Color(0xCC000000),
|
||||||
|
0.40f to Color(0x59000000),
|
||||||
|
0.70f to Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
@@ -440,30 +494,24 @@ private fun HeroPillButton(
|
|||||||
targetValue = if (isFocused) 1.05f else 1.0f,
|
targetValue = if (isFocused) 1.05f else 1.0f,
|
||||||
label = "pill_scale"
|
label = "pill_scale"
|
||||||
)
|
)
|
||||||
val background = when {
|
val primaryBackground = if (isFocused) Color.White else Color(0xD9FFFFFF)
|
||||||
isPrimary && isFocused -> Color.White
|
|
||||||
isPrimary -> Color.White
|
|
||||||
isFocused -> Color(0x55FFFFFF)
|
|
||||||
else -> VylaPillBackground
|
|
||||||
}
|
|
||||||
val contentColor = when {
|
val contentColor = when {
|
||||||
isPrimary -> Color.Black
|
isPrimary -> Color.Black
|
||||||
|
isFocused -> Color.Black
|
||||||
else -> Color.White
|
else -> Color.White
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.scale(scale)
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
.height(48.dp)
|
.height(48.dp)
|
||||||
.clip(RoundedCornerShape(24.dp))
|
.clip(RoundedCornerShape(24.dp))
|
||||||
.background(background)
|
|
||||||
.then(
|
.then(
|
||||||
if (isFocused && isPrimary) {
|
if (isPrimary) Modifier.background(primaryBackground) else Modifier.videoTint(isFocused)
|
||||||
Modifier.border(2.dp, Color(0xFFCCCCCC), RoundedCornerShape(24.dp))
|
|
||||||
} else if (isFocused) {
|
|
||||||
Modifier.border(2.dp, Color.White, RoundedCornerShape(24.dp))
|
|
||||||
} else Modifier
|
|
||||||
)
|
)
|
||||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ import tv.vyla.app.model.*
|
|||||||
import tv.vyla.app.ui.components.MediaCard
|
import tv.vyla.app.ui.components.MediaCard
|
||||||
|
|
||||||
import tv.vyla.app.ui.theme.*
|
import tv.vyla.app.ui.theme.*
|
||||||
|
import androidx.compose.ui.graphics.FilterQuality
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import tv.vyla.app.ui.components.AmbientBackdrop
|
||||||
|
import tv.vyla.app.ui.components.HeroFadeStops
|
||||||
|
import tv.vyla.app.ui.components.verticalFadeMask
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
||||||
fun DetailScreen(
|
fun DetailScreen(
|
||||||
@@ -132,14 +138,15 @@ fun DetailScreen(
|
|||||||
var isMenuOpen by remember { mutableStateOf(false) }
|
var isMenuOpen by remember { mutableStateOf(false) }
|
||||||
var isSeasonDropdownOpen by remember { mutableStateOf(false) }
|
var isSeasonDropdownOpen by remember { mutableStateOf(false) }
|
||||||
val playButtonFocusRequester = remember { FocusRequester() }
|
val playButtonFocusRequester = remember { FocusRequester() }
|
||||||
|
val firstEpisodeFocusRequester = remember { FocusRequester() }
|
||||||
|
var shouldFocusFirstEpisodeAfterSeasonChange by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(item) {
|
LaunchedEffect(item) {
|
||||||
listState.scrollToItem(0)
|
listState.scrollToItem(0)
|
||||||
val d = TmdbApi.getDetail(item.mediaType, item.id)
|
val d = TmdbApi.getDetail(item.mediaType, item.id)
|
||||||
detail = d
|
detail = d
|
||||||
if (d != null && d.seasons.isNotEmpty()) {
|
if (d != null && d.seasons.isNotEmpty()) {
|
||||||
val firstSeason = d.seasons.first()
|
val initialSeason = d.seasons.firstOrNull { it.seasonNumber == 1 } ?: d.seasons.first()
|
||||||
selectedSeason = firstSeason
|
selectedSeason = initialSeason
|
||||||
seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, firstSeason.seasonNumber)
|
|
||||||
}
|
}
|
||||||
delay(150)
|
delay(150)
|
||||||
try {
|
try {
|
||||||
@@ -150,6 +157,13 @@ fun DetailScreen(
|
|||||||
val s = selectedSeason
|
val s = selectedSeason
|
||||||
if (s != null) {
|
if (s != null) {
|
||||||
seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, s.seasonNumber)
|
seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, s.seasonNumber)
|
||||||
|
if (shouldFocusFirstEpisodeAfterSeasonChange) {
|
||||||
|
shouldFocusFirstEpisodeAfterSeasonChange = false
|
||||||
|
delay(120)
|
||||||
|
try {
|
||||||
|
firstEpisodeFocusRequester.requestFocus()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
BackHandler {
|
BackHandler {
|
||||||
@@ -181,42 +195,23 @@ fun DetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(modifier = modifier.fillMaxSize().background(Color(0xFF07070A))) {
|
Box(modifier = modifier.fillMaxSize().background(Color(0xFF07070A))) {
|
||||||
val bg = item.ambientBackdropUrl
|
AmbientBackdrop(
|
||||||
if (!bg.isNullOrEmpty()) {
|
url = item.ambientBackdropUrl,
|
||||||
AsyncImage(
|
modifier = Modifier.fillMaxSize()
|
||||||
model = bg,
|
)
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.blur(60.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val backdrop = item.backdropUrl
|
val backdrop = item.backdropUrl
|
||||||
if (!backdrop.isNullOrEmpty()) {
|
if (!backdrop.isNullOrEmpty()) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight(0.82f)
|
.fillMaxHeight(0.82f)
|
||||||
|
.verticalFadeMask(*HeroFadeStops)
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = backdrop,
|
model = backdrop,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize()
|
||||||
.fillMaxSize()
|
|
||||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
|
||||||
.drawWithContent {
|
|
||||||
drawContent()
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
0.0f to Color.Black,
|
|
||||||
0.45f to Color.Black,
|
|
||||||
1.0f to Color.Transparent
|
|
||||||
),
|
|
||||||
blendMode = BlendMode.DstIn
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,6 +382,26 @@ fun DetailScreen(
|
|||||||
}
|
}
|
||||||
if (hasEpisodes) {
|
if (hasEpisodes) {
|
||||||
item {
|
item {
|
||||||
|
val seasonsList = d?.seasons ?: emptyList()
|
||||||
|
val seasonChipFocusRequesters = remember(d) {
|
||||||
|
mutableMapOf<Int, FocusRequester>()
|
||||||
|
}
|
||||||
|
val seasonRowState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(isSeasonDropdownOpen) {
|
||||||
|
if (isSeasonDropdownOpen) {
|
||||||
|
val targetIndex = seasonsList.indexOfFirst { it.seasonNumber == selectedSeason?.seasonNumber }
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
seasonRowState.scrollToItem(targetIndex)
|
||||||
|
delay(60)
|
||||||
|
try {
|
||||||
|
val targetSeason = seasonsList[targetIndex]
|
||||||
|
seasonChipFocusRequesters[targetSeason.seasonNumber]?.requestFocus()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -414,19 +429,22 @@ fun DetailScreen(
|
|||||||
}
|
}
|
||||||
if (isSeasonDropdownOpen) {
|
if (isSeasonDropdownOpen) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
|
state = seasonRowState,
|
||||||
contentPadding = PaddingValues(horizontal = 48.dp),
|
contentPadding = PaddingValues(horizontal = 48.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(vertical = 18.dp)
|
||||||
.padding(vertical = 10.dp)
|
|
||||||
.background(Color(0x9916161E))
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
) {
|
) {
|
||||||
items(d?.seasons ?: emptyList()) { season ->
|
items(seasonsList) { season ->
|
||||||
|
val requester = seasonChipFocusRequesters.getOrPut(season.seasonNumber) {
|
||||||
|
FocusRequester()
|
||||||
|
}
|
||||||
SeasonSelectChip(
|
SeasonSelectChip(
|
||||||
season = season,
|
season = season,
|
||||||
isSelected = selectedSeason?.seasonNumber == season.seasonNumber,
|
isSelected = selectedSeason?.seasonNumber == season.seasonNumber,
|
||||||
|
focusRequester = requester,
|
||||||
onFocused = { scrollToSection(episodesItemIndex) },
|
onFocused = { scrollToSection(episodesItemIndex) },
|
||||||
onSelect = {
|
onSelect = {
|
||||||
|
shouldFocusFirstEpisodeAfterSeasonChange = true
|
||||||
selectedSeason = season
|
selectedSeason = season
|
||||||
isSeasonDropdownOpen = false
|
isSeasonDropdownOpen = false
|
||||||
}
|
}
|
||||||
@@ -449,15 +467,17 @@ fun DetailScreen(
|
|||||||
modifier = Modifier.padding(vertical = 16.dp)
|
modifier = Modifier.padding(vertical = 16.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
rowsOfEpisodes.forEach { rowEpisodes ->
|
rowsOfEpisodes.forEachIndexed { rowIndex, rowEpisodes ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(18.dp)
|
horizontalArrangement = Arrangement.spacedBy(18.dp)
|
||||||
) {
|
) {
|
||||||
rowEpisodes.forEach { ep ->
|
rowEpisodes.forEachIndexed { colIndex, ep ->
|
||||||
|
val isFirstEpisode = rowIndex == 0 && colIndex == 0
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
DetailEpisodeCard(
|
DetailEpisodeCard(
|
||||||
episode = ep,
|
episode = ep,
|
||||||
|
focusRequester = if (isFirstEpisode) firstEpisodeFocusRequester else null,
|
||||||
onFocused = { scrollToSection(episodesItemIndex) },
|
onFocused = { scrollToSection(episodesItemIndex) },
|
||||||
onClick = { onPlay(item, ep) }
|
onClick = { onPlay(item, ep) }
|
||||||
)
|
)
|
||||||
@@ -770,10 +790,12 @@ private fun SeasonDropdownButton(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SeasonSelectChip(
|
private fun SeasonSelectChip(
|
||||||
season: Season,
|
season: Season,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
onFocused: (() -> Unit)? = null,
|
onFocused: (() -> Unit)? = null,
|
||||||
onSelect: () -> Unit
|
onSelect: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -782,15 +804,15 @@ private fun SeasonSelectChip(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(
|
.background(
|
||||||
when {
|
when {
|
||||||
isFocused -> Color.White
|
isFocused -> Color(0xF2FFFFFF)
|
||||||
isSelected -> Color(0x44FFFFFF)
|
isSelected -> Color(0x66FFFFFF)
|
||||||
else -> Color(0x18FFFFFF)
|
else -> Color(0x99020202)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(12.dp)) else Modifier)
|
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
isFocused = it.isFocused
|
isFocused = it.isFocused
|
||||||
if (it.isFocused) onFocused?.invoke()
|
if (it.isFocused) onFocused?.invoke()
|
||||||
@@ -816,9 +838,11 @@ private fun SeasonSelectChip(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailEpisodeCard(
|
private fun DetailEpisodeCard(
|
||||||
episode: Episode,
|
episode: Episode,
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
onFocused: (() -> Unit)? = null,
|
onFocused: (() -> Unit)? = null,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -828,6 +852,7 @@ private fun DetailEpisodeCard(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.scale(scale)
|
.scale(scale)
|
||||||
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
isFocused = it.isFocused
|
isFocused = it.isFocused
|
||||||
if (it.isFocused) onFocused?.invoke()
|
if (it.isFocused) onFocused?.invoke()
|
||||||
@@ -906,21 +931,34 @@ private fun DetailEpisodeCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailTrailerCard(
|
private fun DetailTrailerCard(
|
||||||
trailer: VideoClip,
|
trailer: VideoClip,
|
||||||
onFocused: (() -> Unit)? = null,
|
onFocused: (() -> Unit)? = null,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
val scale by animateFloatAsState(if (isFocused) 1.05f else 1.0f, label = "tr_scale")
|
val scale by animateFloatAsState(if (isFocused) 1.05f else 1.0f, label = "tr_scale")
|
||||||
Column(
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
val tinyRequest = remember(trailer.key) {
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(trailer.thumbnailUrl)
|
||||||
|
.size(32, 18)
|
||||||
|
.allowHardware(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(280.dp)
|
.width(280.dp)
|
||||||
.scale(scale)
|
.graphicsLayer {
|
||||||
.clip(RoundedCornerShape(16.dp))
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
|
.clip(shape)
|
||||||
.background(Color(0xFF101016))
|
.background(Color(0xFF101016))
|
||||||
.then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(16.dp)) else Modifier)
|
.then(if (isFocused) Modifier.border(2.dp, Color.White, shape) else Modifier)
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
isFocused = it.isFocused
|
isFocused = it.isFocused
|
||||||
if (it.isFocused) onFocused?.invoke()
|
if (it.isFocused) onFocused?.invoke()
|
||||||
@@ -930,53 +968,102 @@ private fun DetailTrailerCard(
|
|||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null
|
indication = null
|
||||||
) { onClick() }
|
) { onClick() }
|
||||||
.padding(bottom = 14.dp)
|
|
||||||
) {
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = tinyRequest,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
filterQuality = FilterQuality.Medium,
|
||||||
|
modifier = Modifier.matchParentSize()
|
||||||
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.matchParentSize()
|
||||||
.aspectRatio(16f / 9f)
|
.background(
|
||||||
.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
|
Brush.verticalGradient(
|
||||||
) {
|
0.0f to Color(0x26000000),
|
||||||
AsyncImage(
|
1.0f to Color(0x80000000)
|
||||||
model = trailer.thumbnailUrl,
|
)
|
||||||
contentDescription = trailer.name,
|
)
|
||||||
contentScale = ContentScale.Crop,
|
)
|
||||||
modifier = Modifier.fillMaxSize()
|
Column {
|
||||||
)
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(44.dp)
|
.fillMaxWidth()
|
||||||
.clip(CircleShape)
|
.aspectRatio(16f / 9f)
|
||||||
.background(Color(0x88000000))
|
.verticalFadeMask(
|
||||||
.align(Alignment.Center),
|
0f to 1f,
|
||||||
contentAlignment = Alignment.Center
|
0.55f to 1f,
|
||||||
|
0.72f to 0.72f,
|
||||||
|
0.86f to 0.30f,
|
||||||
|
1f to 0f
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Icon(
|
AsyncImage(
|
||||||
imageVector = Icons.Default.PlayArrow,
|
model = trailer.thumbnailUrl,
|
||||||
contentDescription = null,
|
contentDescription = trailer.name,
|
||||||
tint = Color.White,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.size(26.dp)
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0x88000000))
|
||||||
|
.align(Alignment.Center),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(26.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 14.dp, end = 14.dp, bottom = 14.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = trailer.type.uppercase(),
|
||||||
|
color = Color(0xB3FFFFFF),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
letterSpacing = 1.2.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = trailer.name,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 6.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayArrow,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xCCFFFFFF),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = trailer.site,
|
||||||
|
color = Color(0xCCFFFFFF),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
text = trailer.name,
|
|
||||||
color = Color(0xFFF2F2F5),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, top = 10.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "▶ ${trailer.type}",
|
|
||||||
color = Color(0xFFD0D0DA),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
modifier = Modifier.padding(start = 14.dp, end = 14.dp, top = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DetailCastPortrait(
|
private fun DetailCastPortrait(
|
||||||
member: CastMember,
|
member: CastMember,
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ import tv.vyla.app.ui.components.MediaCard
|
|||||||
import tv.vyla.app.ui.theme.VylaBackground
|
import tv.vyla.app.ui.theme.VylaBackground
|
||||||
import tv.vyla.app.ui.theme.VylaTextMuted
|
import tv.vyla.app.ui.theme.VylaTextMuted
|
||||||
import tv.vyla.app.ui.theme.VylaTextPrimary
|
import tv.vyla.app.ui.theme.VylaTextPrimary
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import tv.vyla.app.ui.components.AmbientBackdrop
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(
|
||||||
@@ -25,10 +36,27 @@ fun LibraryScreen(
|
|||||||
onOpenDetail: (MediaItem) -> Unit,
|
onOpenDetail: (MediaItem) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Column(
|
var ambientUrl by remember(items) { mutableStateOf(items.firstOrNull()?.ambientBackdropUrl) }
|
||||||
modifier = modifier
|
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
|
||||||
|
AmbientBackdrop(
|
||||||
|
url = ambientUrl,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
durationMs = 700
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0.0f to Color(0x0D000000),
|
||||||
|
0.55f to Color(0x8C000000),
|
||||||
|
1.0f to Color(0xF207070A)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(VylaBackground)
|
|
||||||
.padding(start = 78.dp, end = 48.dp, top = 36.dp)
|
.padding(start = 78.dp, end = 48.dp, top = 36.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -75,10 +103,12 @@ fun LibraryScreen(
|
|||||||
MediaCard(
|
MediaCard(
|
||||||
item = item,
|
item = item,
|
||||||
width = 140,
|
width = 140,
|
||||||
onSelect = { onOpenDetail(item) }
|
onSelect = { onOpenDetail(item) },
|
||||||
|
onFocused = { ambientUrl = it.ambientBackdropUrl }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,150 +1,84 @@
|
|||||||
package tv.vyla.app.ui.screens
|
package tv.vyla.app.ui.screens
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
|
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
|
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.VolumeMute
|
import androidx.compose.material.icons.automirrored.filled.VolumeMute
|
||||||
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||||
|
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
import androidx.compose.ui.input.key.*
|
import androidx.compose.ui.input.key.*
|
||||||
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
|
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
|
|
||||||
import androidx.media3.common.MediaItem as ExoMediaItem
|
import androidx.media3.common.MediaItem as ExoMediaItem
|
||||||
|
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
|
|
||||||
import androidx.media3.common.PlaybackParameters
|
import androidx.media3.common.PlaybackParameters
|
||||||
|
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
|
||||||
import androidx.media3.common.TrackSelectionParameters
|
import androidx.media3.common.TrackSelectionParameters
|
||||||
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
|
||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
|
|
||||||
import androidx.media3.ui.PlayerView
|
import androidx.media3.ui.PlayerView
|
||||||
|
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import tv.vyla.app.api.IntroDbV3
|
||||||
import tv.vyla.app.api.IntroDbApi
|
import tv.vyla.app.api.SkipSegment
|
||||||
|
|
||||||
import tv.vyla.app.api.TmdbApi
|
import tv.vyla.app.api.TmdbApi
|
||||||
|
|
||||||
import tv.vyla.app.data.LibraryManager
|
import tv.vyla.app.data.LibraryManager
|
||||||
|
|
||||||
import tv.vyla.app.model.*
|
import tv.vyla.app.model.*
|
||||||
|
|
||||||
import tv.vyla.app.proxy.LocalProxy
|
import tv.vyla.app.proxy.LocalProxy
|
||||||
|
|
||||||
import tv.vyla.app.source.VylaResolver
|
import tv.vyla.app.source.VylaResolver
|
||||||
|
|
||||||
import tv.vyla.app.ui.theme.*
|
import tv.vyla.app.ui.theme.*
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import tv.vyla.app.ui.components.VideoTintSampler
|
||||||
|
import tv.vyla.app.ui.components.videoTint
|
||||||
|
|
||||||
enum class ActivePlayerMenu {
|
enum class ActivePlayerMenu {
|
||||||
NONE, QUALITY, SUBTITLES, VOLUME, SETTINGS, MORE
|
NONE, QUALITY, SUBTITLES, VOLUME, SETTINGS, MORE
|
||||||
@@ -175,8 +109,11 @@ fun PlayerScreen(
|
|||||||
var currentPositionMs by remember { mutableLongStateOf(0L) }
|
var currentPositionMs by remember { mutableLongStateOf(0L) }
|
||||||
var durationMs by remember { mutableLongStateOf(0L) }
|
var durationMs by remember { mutableLongStateOf(0L) }
|
||||||
var bufferedPositionMs by remember { mutableLongStateOf(0L) }
|
var bufferedPositionMs by remember { mutableLongStateOf(0L) }
|
||||||
val segments = remember { mutableStateListOf<MediaSegment>() }
|
val segments = remember { mutableStateListOf<SkipSegment>() }
|
||||||
var activeSegment by remember { mutableStateOf<MediaSegment?>(null) }
|
var activeSegment by remember { mutableStateOf<SkipSegment?>(null) }
|
||||||
|
val skipButtonFocusRequester = remember { FocusRequester() }
|
||||||
|
val nextButtonFocusRequester = remember { FocusRequester() }
|
||||||
|
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
|
||||||
var areControlsVisible by remember { mutableStateOf(true) }
|
var areControlsVisible by remember { mutableStateOf(true) }
|
||||||
var activeMenu by remember { mutableStateOf(ActivePlayerMenu.NONE) }
|
var activeMenu by remember { mutableStateOf(ActivePlayerMenu.NONE) }
|
||||||
var isEpisodesDrawerOpen by remember { mutableStateOf(false) }
|
var isEpisodesDrawerOpen by remember { mutableStateOf(false) }
|
||||||
@@ -190,6 +127,9 @@ fun PlayerScreen(
|
|||||||
val seekbarFocusRequester = remember { FocusRequester() }
|
val seekbarFocusRequester = remember { FocusRequester() }
|
||||||
val menuFirstItemFocusRequester = remember { FocusRequester() }
|
val menuFirstItemFocusRequester = remember { FocusRequester() }
|
||||||
val episodesButtonFocusRequester = remember { FocusRequester() }
|
val episodesButtonFocusRequester = remember { FocusRequester() }
|
||||||
|
val episodeFocusRequester = remember { FocusRequester() }
|
||||||
|
val episodesListState = rememberLazyListState()
|
||||||
|
var episodeFocusIndex by remember { mutableIntStateOf(0) }
|
||||||
val qualityButtonFocusRequester = remember { FocusRequester() }
|
val qualityButtonFocusRequester = remember { FocusRequester() }
|
||||||
val ccButtonFocusRequester = remember { FocusRequester() }
|
val ccButtonFocusRequester = remember { FocusRequester() }
|
||||||
val volButtonFocusRequester = remember { FocusRequester() }
|
val volButtonFocusRequester = remember { FocusRequester() }
|
||||||
@@ -238,18 +178,22 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
LaunchedEffect(exoPlayer) {
|
LaunchedEffect(exoPlayer) {
|
||||||
while (true) {
|
while (true) {
|
||||||
currentPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L)
|
val pos = exoPlayer.currentPosition.coerceAtLeast(0L)
|
||||||
bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0L)
|
|
||||||
val dur = exoPlayer.duration
|
val dur = exoPlayer.duration
|
||||||
if (dur > 0L) durationMs = dur
|
if (dur > 0L && dur != durationMs) durationMs = dur
|
||||||
val currentSec = currentPositionMs / 1000.0
|
val nearEnd = dur > 0L && dur - pos <= 300000L
|
||||||
val seg = segments.firstOrNull { s ->
|
if (areControlsVisible || nearEnd) {
|
||||||
val afterStart = currentSec >= s.start
|
if (pos != currentPositionMs) currentPositionMs = pos
|
||||||
val beforeEnd = s.end == null || currentSec <= s.end
|
val buffered = exoPlayer.bufferedPosition.coerceAtLeast(0L)
|
||||||
afterStart && beforeEnd
|
if (buffered != bufferedPositionMs) bufferedPositionMs = buffered
|
||||||
}
|
}
|
||||||
activeSegment = seg
|
val currentSec = pos / 1000.0
|
||||||
delay(300)
|
val seg = segments.firstOrNull { s ->
|
||||||
|
val endSec = s.end
|
||||||
|
currentSec >= s.start && (endSec == null || currentSec < endSec)
|
||||||
|
}
|
||||||
|
if (seg != activeSegment) activeSegment = seg
|
||||||
|
delay(if (areControlsVisible) 300L else 600L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
@@ -276,11 +220,43 @@ fun PlayerScreen(
|
|||||||
exoPlayer.play()
|
exoPlayer.play()
|
||||||
resetHideTimer()
|
resetHideTimer()
|
||||||
}
|
}
|
||||||
fun loadSeasonEpisodes(seasonNum: Int) {
|
suspend fun focusEpisodeCard() {
|
||||||
|
var tries = 0
|
||||||
|
while (tries < 15) {
|
||||||
|
tries++
|
||||||
|
delay(60)
|
||||||
|
try {
|
||||||
|
episodeFocusRequester.requestFocus()
|
||||||
|
return
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun loadSeasonEpisodes(seasonNum: Int, focusFirst: Boolean = false) {
|
||||||
selectedSeasonNum = seasonNum
|
selectedSeasonNum = seasonNum
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val eps = TmdbApi.getSeasonEpisodes(item.id, seasonNum)
|
val eps = TmdbApi.getSeasonEpisodes(item.id, seasonNum)
|
||||||
allEpisodes = eps
|
allEpisodes = eps
|
||||||
|
if (focusFirst && eps.isNotEmpty()) {
|
||||||
|
episodeFocusIndex = 0
|
||||||
|
episodesListState.scrollToItem(0)
|
||||||
|
focusEpisodeCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(isEpisodesDrawerOpen) {
|
||||||
|
if (isEpisodesDrawerOpen) {
|
||||||
|
withTimeoutOrNull(5000) { snapshotFlow { allEpisodes }.first { it.isNotEmpty() } }
|
||||||
|
val ep = currentEpisode
|
||||||
|
val idx = if (ep != null) {
|
||||||
|
allEpisodes.indexOfFirst {
|
||||||
|
it.seasonNumber == ep.seasonNumber && it.episodeNumber == ep.episodeNumber
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
episodeFocusIndex = if (idx >= 0) idx else 0
|
||||||
|
episodesListState.scrollToItem(episodeFocusIndex)
|
||||||
|
focusEpisodeCard()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(item) {
|
LaunchedEffect(item) {
|
||||||
@@ -304,7 +280,7 @@ fun PlayerScreen(
|
|||||||
val s = ep?.seasonNumber
|
val s = ep?.seasonNumber
|
||||||
val e = ep?.episodeNumber
|
val e = ep?.episodeNumber
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val segList = IntroDbApi.getTimestamps(item.id, s, e)
|
val segList = IntroDbV3.getSegments(item.id, s, e)
|
||||||
segments.clear()
|
segments.clear()
|
||||||
segments.addAll(segList)
|
segments.addAll(segList)
|
||||||
}
|
}
|
||||||
@@ -333,9 +309,29 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(activeStreamUrl) {
|
||||||
|
if (activeStreamUrl == null) return@LaunchedEffect
|
||||||
|
var waited = 0
|
||||||
|
while (exoPlayer.duration <= 0L && waited < 30) {
|
||||||
|
delay(500)
|
||||||
|
waited++
|
||||||
|
}
|
||||||
|
val dur = exoPlayer.duration
|
||||||
|
if (dur > 0L) {
|
||||||
|
val ep = currentEpisode
|
||||||
|
val refined = IntroDbV3.getSegments(item.id, ep?.seasonNumber, ep?.episodeNumber, dur)
|
||||||
|
if (refined.isNotEmpty()) {
|
||||||
|
segments.clear()
|
||||||
|
segments.addAll(refined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
showControlsAndFocusPlay()
|
showControlsAndFocusPlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler {
|
BackHandler {
|
||||||
when {
|
when {
|
||||||
activeMenu != ActivePlayerMenu.NONE -> {
|
activeMenu != ActivePlayerMenu.NONE -> {
|
||||||
@@ -399,6 +395,27 @@ fun PlayerScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun skipSegment(seg: SkipSegment) {
|
||||||
|
val endSec = seg.end
|
||||||
|
if (endSec == null) {
|
||||||
|
if (isNextEpAvailable) {
|
||||||
|
playNextEpisode()
|
||||||
|
} else if (durationMs > 0L) {
|
||||||
|
exoPlayer.seekTo(durationMs)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val targetMs = (endSec * 1000.0).toLong() + 200L
|
||||||
|
exoPlayer.seekTo(if (durationMs > 0L) targetMs.coerceAtMost(durationMs) else targetMs)
|
||||||
|
}
|
||||||
|
activeSegment = null
|
||||||
|
resetHideTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoTintSampler(active = areControlsVisible || activeSegment != null) {
|
||||||
|
playerViewRef?.videoSurfaceView
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -409,6 +426,14 @@ fun PlayerScreen(
|
|||||||
val code = event.nativeKeyEvent.keyCode
|
val code = event.nativeKeyEvent.keyCode
|
||||||
|
|
||||||
if (!areControlsVisible) {
|
if (!areControlsVisible) {
|
||||||
|
val pendingSegment = activeSegment
|
||||||
|
val isConfirm = code == KeyEvent.KEYCODE_DPAD_CENTER ||
|
||||||
|
code == KeyEvent.KEYCODE_ENTER ||
|
||||||
|
code == KeyEvent.KEYCODE_NUMPAD_ENTER
|
||||||
|
if (pendingSegment != null && isConfirm && activeMenu == ActivePlayerMenu.NONE && !isEpisodesDrawerOpen) {
|
||||||
|
skipSegment(pendingSegment)
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
if (code == KeyEvent.KEYCODE_DPAD_CENTER) {
|
if (code == KeyEvent.KEYCODE_DPAD_CENTER) {
|
||||||
if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play()
|
if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play()
|
||||||
}
|
}
|
||||||
@@ -476,9 +501,12 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
update = { view ->
|
update = { view ->
|
||||||
|
if (playerViewRef !== view) playerViewRef = view
|
||||||
view.resizeMode = if (isFillScreen) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT
|
view.resizeMode = if (isFillScreen) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
},
|
},
|
||||||
|
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
if (isBuffering) {
|
if (isBuffering) {
|
||||||
@@ -501,12 +529,8 @@ fun PlayerScreen(
|
|||||||
PlayerPillButton(
|
PlayerPillButton(
|
||||||
text = seg.label,
|
text = seg.label,
|
||||||
icon = Icons.Default.FastForward,
|
icon = Icons.Default.FastForward,
|
||||||
onClick = {
|
focusRequester = skipButtonFocusRequester,
|
||||||
val targetMs = ((seg.end ?: ((currentPositionMs / 1000.0) + 30.0)) + 0.5) * 1000.0
|
onClick = { skipSegment(seg) }
|
||||||
exoPlayer.seekTo(targetMs.toLong().coerceAtMost(durationMs))
|
|
||||||
activeSegment = null
|
|
||||||
resetHideTimer()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (showNextPrompt) {
|
if (showNextPrompt) {
|
||||||
@@ -514,6 +538,7 @@ fun PlayerScreen(
|
|||||||
text = "Next Episode",
|
text = "Next Episode",
|
||||||
icon = Icons.Default.SkipNext,
|
icon = Icons.Default.SkipNext,
|
||||||
isPrimary = true,
|
isPrimary = true,
|
||||||
|
focusRequester = nextButtonFocusRequester,
|
||||||
onClick = {
|
onClick = {
|
||||||
playNextEpisode()
|
playNextEpisode()
|
||||||
resetHideTimer()
|
resetHideTimer()
|
||||||
@@ -725,7 +750,13 @@ fun PlayerScreen(
|
|||||||
},
|
},
|
||||||
onUpPress = {
|
onUpPress = {
|
||||||
try {
|
try {
|
||||||
moreButtonFocusRequester.requestFocus()
|
if (seg != null) {
|
||||||
|
skipButtonFocusRequester.requestFocus()
|
||||||
|
} else if (showNextPrompt) {
|
||||||
|
nextButtonFocusRequester.requestFocus()
|
||||||
|
} else {
|
||||||
|
moreButtonFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1024,7 +1055,7 @@ fun PlayerScreen(
|
|||||||
season = season,
|
season = season,
|
||||||
isSelected = selectedSeasonNum == season.seasonNumber,
|
isSelected = selectedSeasonNum == season.seasonNumber,
|
||||||
onSelect = {
|
onSelect = {
|
||||||
loadSeasonEpisodes(season.seasonNumber)
|
loadSeasonEpisodes(season.seasonNumber, true)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1044,16 +1075,18 @@ fun PlayerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
LazyRow(
|
LazyRow(
|
||||||
|
state = episodesListState,
|
||||||
contentPadding = PaddingValues(horizontal = 36.dp),
|
contentPadding = PaddingValues(horizontal = 36.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
modifier = Modifier.padding(top = 16.dp)
|
modifier = Modifier.padding(top = 16.dp)
|
||||||
) {
|
) {
|
||||||
items(allEpisodes) { ep ->
|
itemsIndexed(allEpisodes) { index, ep ->
|
||||||
val isCur = currentEpisode?.seasonNumber == ep.seasonNumber &&
|
val isCur = currentEpisode?.seasonNumber == ep.seasonNumber &&
|
||||||
currentEpisode?.episodeNumber == ep.episodeNumber
|
currentEpisode?.episodeNumber == ep.episodeNumber
|
||||||
DrawerEpisodeCard(
|
DrawerEpisodeCard(
|
||||||
episode = ep,
|
episode = ep,
|
||||||
isCurrentlyPlaying = isCur,
|
isCurrentlyPlaying = isCur,
|
||||||
|
focusRequester = if (index == episodeFocusIndex) episodeFocusRequester else null,
|
||||||
onClick = {
|
onClick = {
|
||||||
currentEpisode = ep
|
currentEpisode = ep
|
||||||
isEpisodesDrawerOpen = false
|
isEpisodesDrawerOpen = false
|
||||||
@@ -1084,14 +1117,7 @@ private fun PlayerCircleButton(
|
|||||||
.scale(scale)
|
.scale(scale)
|
||||||
.size(46.dp)
|
.size(46.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(
|
.videoTint(isFocused, isSelected)
|
||||||
when {
|
|
||||||
isFocused -> Color.White
|
|
||||||
isSelected -> Color(0x55FFFFFF)
|
|
||||||
else -> Color(0x22FFFFFF)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(if (isFocused) Modifier.border(2.dp, Color.White, CircleShape) else Modifier)
|
|
||||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.onKeyEvent { event ->
|
.onKeyEvent { event ->
|
||||||
@@ -1130,11 +1156,7 @@ private fun PlayerEpisodesButton(
|
|||||||
.scale(scale)
|
.scale(scale)
|
||||||
.size(46.dp)
|
.size(46.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (isFocused) Color.White else Color(0x2EFFFFFF))
|
.videoTint(isFocused)
|
||||||
.then(
|
|
||||||
if (isFocused) Modifier.border(2.dp, Color(0xFF007AFF), CircleShape)
|
|
||||||
else Modifier.border(1.dp, Color(0x33FFFFFF), CircleShape)
|
|
||||||
)
|
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.onKeyEvent { event ->
|
.onKeyEvent { event ->
|
||||||
@@ -1181,8 +1203,7 @@ private fun PlayerCenterPlayButton(
|
|||||||
.scale(scale)
|
.scale(scale)
|
||||||
.size(100.dp)
|
.size(100.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(if (isFocused) Color(0x44FFFFFF) else Color(0x18FFFFFF))
|
.videoTint(isFocused)
|
||||||
.then(if (isFocused) Modifier.border(2.5.dp, Color.White, CircleShape) else Modifier)
|
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.onKeyEvent { event ->
|
.onKeyEvent { event ->
|
||||||
@@ -1201,7 +1222,7 @@ private fun PlayerCenterPlayButton(
|
|||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||||
tint = Color.White,
|
tint = if (isFocused) Color.Black else Color.White,
|
||||||
modifier = Modifier.size(60.dp)
|
modifier = Modifier.size(60.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1211,6 +1232,7 @@ private fun PlayerPillButton(
|
|||||||
text: String,
|
text: String,
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
isPrimary: Boolean = false,
|
isPrimary: Boolean = false,
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
@@ -1222,14 +1244,14 @@ private fun PlayerPillButton(
|
|||||||
.scale(scale)
|
.scale(scale)
|
||||||
.height(44.dp)
|
.height(44.dp)
|
||||||
.clip(RoundedCornerShape(22.dp))
|
.clip(RoundedCornerShape(22.dp))
|
||||||
.background(
|
.then(
|
||||||
when {
|
if (isPrimary) {
|
||||||
isFocused -> Color.White
|
Modifier.background(if (isFocused) Color(0xF2FFFFFF) else Color(0xE6FFFFFF))
|
||||||
isPrimary -> Color(0xE6FFFFFF)
|
} else {
|
||||||
else -> Color(0x33FFFFFF)
|
Modifier.videoTint(isFocused)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(22.dp)) else Modifier)
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.focusable()
|
.focusable()
|
||||||
.clickable(
|
.clickable(
|
||||||
@@ -1470,6 +1492,7 @@ private fun MiniSelectButton(
|
|||||||
private fun DrawerEpisodeCard(
|
private fun DrawerEpisodeCard(
|
||||||
episode: Episode,
|
episode: Episode,
|
||||||
isCurrentlyPlaying: Boolean,
|
isCurrentlyPlaying: Boolean,
|
||||||
|
focusRequester: FocusRequester? = null,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
@@ -1478,6 +1501,7 @@ private fun DrawerEpisodeCard(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(260.dp)
|
.width(260.dp)
|
||||||
.scale(scale)
|
.scale(scale)
|
||||||
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.focusable()
|
.focusable()
|
||||||
.clickable(
|
.clickable(
|
||||||
@@ -1493,7 +1517,7 @@ private fun DrawerEpisodeCard(
|
|||||||
.background(Color(0xFF101016))
|
.background(Color(0xFF101016))
|
||||||
.then(
|
.then(
|
||||||
if (isFocused) Modifier.border(2.5.dp, Color.White, RoundedCornerShape(10.dp))
|
if (isFocused) Modifier.border(2.5.dp, Color.White, RoundedCornerShape(10.dp))
|
||||||
else if (isCurrentlyPlaying) Modifier.border(2.dp, Color(0xFF007AFF), RoundedCornerShape(10.dp))
|
else if (isCurrentlyPlaying) Modifier.border(2.dp, Color(0xB3FFFFFF), RoundedCornerShape(10.dp))
|
||||||
else Modifier.border(1.dp, Color(0x22FFFFFF), RoundedCornerShape(10.dp))
|
else Modifier.border(1.dp, Color(0x22FFFFFF), RoundedCornerShape(10.dp))
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
@@ -1538,13 +1562,13 @@ private fun DrawerEpisodeCard(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(8.dp)
|
.padding(8.dp)
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.clip(RoundedCornerShape(6.dp))
|
||||||
.background(Color(0xCC007AFF))
|
.background(Color(0xE6FFFFFF))
|
||||||
.padding(horizontal = 8.dp, vertical = 3.dp)
|
.padding(horizontal = 8.dp, vertical = 3.dp)
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "PLAYING",
|
text = "PLAYING",
|
||||||
color = Color.White,
|
color = Color.Black,
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
@@ -1582,14 +1606,7 @@ private fun SeasonSelectChip(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(36.dp)
|
.height(36.dp)
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.background(
|
.videoTint(isFocused, isSelected)
|
||||||
when {
|
|
||||||
isFocused -> Color.White
|
|
||||||
isSelected -> Color(0x44FFFFFF)
|
|
||||||
else -> Color(0x22FFFFFF)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(if (isFocused) Modifier.border(2.dp, Color.White, RoundedCornerShape(18.dp)) else Modifier)
|
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged { isFocused = it.isFocused }
|
||||||
.focusable()
|
.focusable()
|
||||||
.clickable(
|
.clickable(
|
||||||
|
|||||||
@@ -1,96 +1,56 @@
|
|||||||
package tv.vyla.app.ui.screens
|
package tv.vyla.app.ui.screens
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
|
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Backspace
|
||||||
import androidx.compose.material.icons.filled.Backspace
|
|
||||||
|
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
import androidx.compose.ui.draw.blur
|
import androidx.compose.ui.draw.blur
|
||||||
|
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
import androidx.compose.ui.input.key.*
|
import androidx.compose.ui.input.key.*
|
||||||
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
|
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
import tv.vyla.app.api.TmdbApi
|
import tv.vyla.app.api.TmdbApi
|
||||||
|
|
||||||
import tv.vyla.app.model.MediaItem
|
import tv.vyla.app.model.MediaItem
|
||||||
|
|
||||||
import tv.vyla.app.ui.theme.*
|
import tv.vyla.app.ui.theme.*
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import tv.vyla.app.ui.components.SearchIcons
|
||||||
|
import tv.vyla.app.ui.components.AmbientBackdrop
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
||||||
fun SearchScreen(
|
fun SearchScreen(
|
||||||
@@ -174,22 +134,10 @@ fun SearchScreen(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Crossfade(
|
AmbientBackdrop(
|
||||||
targetState = focusedItem?.ambientBackdropUrl,
|
url = focusedItem?.ambientBackdropUrl,
|
||||||
animationSpec = tween(600),
|
modifier = Modifier.fillMaxSize()
|
||||||
label = "search_bg"
|
)
|
||||||
) { url ->
|
|
||||||
if (!url.isNullOrEmpty()) {
|
|
||||||
AsyncImage(
|
|
||||||
model = url,
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.blur(70.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -214,7 +162,7 @@ fun SearchScreen(
|
|||||||
.height(64.dp)
|
.height(64.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = SearchIcons.Search,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color(0xD9FFFFFF),
|
tint = Color(0xD9FFFFFF),
|
||||||
modifier = Modifier.size(38.dp)
|
modifier = Modifier.size(38.dp)
|
||||||
@@ -235,13 +183,14 @@ fun SearchScreen(
|
|||||||
.padding(start = 6.dp)
|
.padding(start = 6.dp)
|
||||||
.width(3.dp)
|
.width(3.dp)
|
||||||
.height(30.dp)
|
.height(30.dp)
|
||||||
.background(Color.White.copy(alpha = cursorAlpha))
|
.drawBehind { drawRect(Color.White, alpha = cursorAlpha) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
val keyboardState = rememberLazyListState()
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
@@ -256,37 +205,62 @@ fun SearchScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
isNumbersMode = !isNumbersMode
|
isNumbersMode = !isNumbersMode
|
||||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||||
|
},
|
||||||
|
onFocus = {
|
||||||
|
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
SearchKeyButton(
|
SearchKeyButton(
|
||||||
label = "SPACE",
|
label = "",
|
||||||
isWord = true,
|
isWord = true,
|
||||||
|
icon = SearchIcons.Space,
|
||||||
|
description = "Space",
|
||||||
onClick = {
|
onClick = {
|
||||||
if (query.isNotEmpty() && !query.endsWith(" ")) {
|
if (query.isNotEmpty() && !query.endsWith(" ")) {
|
||||||
query += " "
|
query += " "
|
||||||
performSearch(query)
|
performSearch(query)
|
||||||
}
|
}
|
||||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||||
|
},
|
||||||
|
onFocus = {
|
||||||
|
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
val activeKeys = if (isNumbersMode) numbersAndSymbols else letters
|
LazyRow(
|
||||||
activeKeys.forEachIndexed { idx, keyChar ->
|
state = keyboardState,
|
||||||
val isFirstLetter = !isNumbersMode && idx == 0
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
SearchKeyButton(
|
modifier = Modifier.weight(1f)
|
||||||
label = keyChar,
|
) {
|
||||||
isWord = false,
|
val activeKeys = if (isNumbersMode) numbersAndSymbols else letters
|
||||||
focusRequester = if (isFirstLetter) letterAFocusRequester else null,
|
items(activeKeys.size) { idx ->
|
||||||
onClick = {
|
val keyChar = activeKeys[idx]
|
||||||
query += keyChar
|
val isFirstLetter = !isNumbersMode && idx == 0
|
||||||
performSearch(query)
|
SearchKeyButton(
|
||||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
label = keyChar,
|
||||||
}
|
isWord = false,
|
||||||
)
|
focusRequester = if (isFirstLetter) letterAFocusRequester else null,
|
||||||
|
onClick = {
|
||||||
|
query += keyChar
|
||||||
|
performSearch(query)
|
||||||
|
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||||
|
},
|
||||||
|
onFocus = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
val viewportWidth = keyboardState.layoutInfo.viewportEndOffset - keyboardState.layoutInfo.viewportStartOffset
|
||||||
|
keyboardState.animateScrollToItem(
|
||||||
|
idx,
|
||||||
|
-(viewportWidth / 2 - 20)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SearchKeyButton(
|
SearchKeyButton(
|
||||||
label = "",
|
label = "",
|
||||||
isWord = false,
|
isWord = false,
|
||||||
isDelete = true,
|
icon = SearchIcons.Backspace,
|
||||||
|
description = "Backspace",
|
||||||
onClick = {
|
onClick = {
|
||||||
if (query.isNotEmpty()) {
|
if (query.isNotEmpty()) {
|
||||||
query = query.dropLast(1)
|
query = query.dropLast(1)
|
||||||
@@ -321,6 +295,9 @@ fun SearchScreen(
|
|||||||
query = name
|
query = name
|
||||||
performSearch(query)
|
performSearch(query)
|
||||||
coroutineScope.launch { listState.animateScrollToItem(0) }
|
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||||
|
},
|
||||||
|
onFocus = {
|
||||||
|
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -328,18 +305,36 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
val tabs = if (query.isNotBlank()) {
|
||||||
|
listOf("Top Results", "Movies", "TV Shows")
|
||||||
|
} else {
|
||||||
|
listOf("Top Results")
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(query.isBlank()) {
|
||||||
|
if (query.isBlank() && selectedTab != 0) {
|
||||||
|
selectedTab = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 18.dp)) {
|
Column(modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 18.dp)) {
|
||||||
HorizontalDivider(color = Color(0x38FFFFFF), thickness = 1.dp)
|
HorizontalDivider(color = Color(0x38FFFFFF), thickness = 1.dp)
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceAround
|
horizontalArrangement = if (tabs.size > 1) Arrangement.SpaceAround else Arrangement.Center
|
||||||
) {
|
) {
|
||||||
listOf("Top Results", "Movies", "TV Shows").forEachIndexed { idx, title ->
|
tabs.forEachIndexed { idx, title ->
|
||||||
SearchTabButton(
|
SearchTabButton(
|
||||||
title = title,
|
title = title,
|
||||||
isSelected = selectedTab == idx,
|
isSelected = selectedTab == idx,
|
||||||
onClick = { selectedTab = idx }
|
onClick = {
|
||||||
|
selectedTab = idx
|
||||||
|
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||||
|
},
|
||||||
|
onFocus = {
|
||||||
|
coroutineScope.launch { listState.animateScrollToItem(0) }
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,10 +407,12 @@ fun SearchScreen(
|
|||||||
private fun SearchKeyButton(
|
private fun SearchKeyButton(
|
||||||
label: String,
|
label: String,
|
||||||
isWord: Boolean,
|
isWord: Boolean,
|
||||||
isDelete: Boolean = false,
|
icon: ImageVector? = null,
|
||||||
|
description: String? = null,
|
||||||
focusRequester: FocusRequester? = null,
|
focusRequester: FocusRequester? = null,
|
||||||
onLeftPress: (() -> Unit)? = null,
|
onLeftPress: (() -> Unit)? = null,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
|
onFocus: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
val scale by animateFloatAsState(if (isFocused) 1.22f else 1.0f, label = "key_scale")
|
val scale by animateFloatAsState(if (isFocused) 1.22f else 1.0f, label = "key_scale")
|
||||||
@@ -437,7 +434,10 @@ private fun SearchKeyButton(
|
|||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(bg)
|
.background(bg)
|
||||||
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged {
|
||||||
|
isFocused = it.isFocused
|
||||||
|
if (it.isFocused) onFocus()
|
||||||
|
}
|
||||||
.onKeyEvent { event ->
|
.onKeyEvent { event ->
|
||||||
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && onLeftPress != null) {
|
if (event.type == KeyEventType.KeyDown && event.nativeKeyEvent.keyCode == android.view.KeyEvent.KEYCODE_DPAD_LEFT && onLeftPress != null) {
|
||||||
onLeftPress()
|
onLeftPress()
|
||||||
@@ -453,12 +453,12 @@ private fun SearchKeyButton(
|
|||||||
.padding(horizontal = if (isWord) 12.dp else 0.dp)
|
.padding(horizontal = if (isWord) 12.dp else 0.dp)
|
||||||
.zIndex(if (isFocused) 5f else 1f)
|
.zIndex(if (isFocused) 5f else 1f)
|
||||||
) {
|
) {
|
||||||
if (isDelete) {
|
if (icon != null) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Backspace,
|
imageVector = icon,
|
||||||
contentDescription = "Backspace",
|
contentDescription = description,
|
||||||
tint = contentColor,
|
tint = contentColor,
|
||||||
modifier = Modifier.size(18.dp)
|
modifier = Modifier.size(if (isWord) 22.dp else 20.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
@@ -474,7 +474,8 @@ private fun SearchKeyButton(
|
|||||||
private fun SearchChip(
|
private fun SearchChip(
|
||||||
text: String,
|
text: String,
|
||||||
showSearchIcon: Boolean = false,
|
showSearchIcon: Boolean = false,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
|
onFocus: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
val scale by animateFloatAsState(if (isFocused) 1.06f else 1.0f, label = "chip_scale")
|
val scale by animateFloatAsState(if (isFocused) 1.06f else 1.0f, label = "chip_scale")
|
||||||
@@ -486,7 +487,10 @@ private fun SearchChip(
|
|||||||
.height(40.dp)
|
.height(40.dp)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(if (isFocused) Color.White else Color(0x3DFFFFFF))
|
.background(if (isFocused) Color.White else Color(0x3DFFFFFF))
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged {
|
||||||
|
isFocused = it.isFocused
|
||||||
|
if (it.isFocused) onFocus()
|
||||||
|
}
|
||||||
.focusable()
|
.focusable()
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
@@ -496,7 +500,7 @@ private fun SearchChip(
|
|||||||
) {
|
) {
|
||||||
if (showSearchIcon) {
|
if (showSearchIcon) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Search,
|
imageVector = SearchIcons.Search,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = if (isFocused) Color.Black else Color.White,
|
tint = if (isFocused) Color.Black else Color.White,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
@@ -514,7 +518,8 @@ private fun SearchChip(
|
|||||||
private fun SearchTabButton(
|
private fun SearchTabButton(
|
||||||
title: String,
|
title: String,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit,
|
||||||
|
onFocus: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var isFocused by remember { mutableStateOf(false) }
|
var isFocused by remember { mutableStateOf(false) }
|
||||||
Column(
|
Column(
|
||||||
@@ -522,7 +527,10 @@ private fun SearchTabButton(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(999.dp))
|
.clip(RoundedCornerShape(999.dp))
|
||||||
.background(if (isFocused) Color(0x33FFFFFF) else Color.Transparent)
|
.background(if (isFocused) Color(0x33FFFFFF) else Color.Transparent)
|
||||||
.onFocusChanged { isFocused = it.isFocused }
|
.onFocusChanged {
|
||||||
|
isFocused = it.isFocused
|
||||||
|
if (it.isFocused) onFocus()
|
||||||
|
}
|
||||||
.focusable()
|
.focusable()
|
||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
@@ -536,13 +544,6 @@ private fun SearchTabButton(
|
|||||||
fontSize = 15.sp,
|
fontSize = 15.sp,
|
||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(28.dp)
|
|
||||||
.height(2.dp)
|
|
||||||
.background(if (isSelected) Color.White else Color.Transparent)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Composable
|
@Composable
|
||||||
@@ -556,7 +557,10 @@ private fun SearchMediaCard(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(140.dp)
|
.width(140.dp)
|
||||||
.scale(scale)
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
.offset(y = if (isFocused) (-3).dp else 0.dp)
|
.offset(y = if (isFocused) (-3).dp else 0.dp)
|
||||||
.onFocusChanged {
|
.onFocusChanged {
|
||||||
isFocused = it.isFocused
|
isFocused = it.isFocused
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.Dp
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.darkColorScheme
|
import androidx.tv.material3.darkColorScheme
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
|
||||||
val VylaBackground = Color(0xFF07070A)
|
val VylaBackground = Color(0xFF07070A)
|
||||||
val VylaCardBackground = Color(0xFF1B1B24)
|
val VylaCardBackground = Color(0xFF1B1B24)
|
||||||
@@ -60,7 +61,10 @@ fun Modifier.tvFocusable(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return this
|
return this
|
||||||
.scale(scale)
|
.graphicsLayer {
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
}
|
||||||
.then(
|
.then(
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
Modifier
|
Modifier
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.getcapacitor.myapp;
|
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
|
||||||
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
|
||||||
*
|
|
||||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
|
||||||
*/
|
|
||||||
public class ExampleUnitTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void addition_isCorrect() throws Exception {
|
|
||||||
assertEquals(4, 2 + 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1525
package-lock.json
generated
1525
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "tv",
|
"name": "tv",
|
||||||
"version": "1.0.3",
|
"version": "1.0.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:web": "node -e \"const fs = require('fs'); fs.rmSync('www', { recursive: true, force: true }); fs.mkdirSync('www'); ['css', 'js', 'assets'].forEach(d => fs.cpSync(d, 'www/' + d, { recursive: true })); fs.copyFileSync('index.html', 'www/index.html');\"",
|
|
||||||
"dev": "node scripts/build-apk.mjs",
|
"dev": "node scripts/build-apk.mjs",
|
||||||
"release": "node scripts/release.mjs",
|
"release": "node scripts/release.mjs",
|
||||||
"android": "node scripts/build-android.mjs"
|
"android": "node scripts/build-android.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor/android": "^6.2.2",
|
|
||||||
"@capacitor/cli": "^6.2.2",
|
|
||||||
"@capacitor/core": "^6.2.2",
|
|
||||||
"@vyla-entertainment/sdk": "^1.14.0",
|
"@vyla-entertainment/sdk": "^1.14.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Vyla TV",
|
"name": "Vyla TV",
|
||||||
"version": "1.0.2",
|
"version": "1.0.4",
|
||||||
"package": "Vyla-TV_1.0.2.apk",
|
"package": "Vyla-TV_1.0.4.apk",
|
||||||
"releaseDate": "2026-10-05T05:03:55.138Z"
|
"releaseDate": "2026-10-05T20:22:35.051Z"
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,6 @@ const version = pkg.version || '1.0.0';
|
|||||||
const tag = `v${version}`;
|
const tag = `v${version}`;
|
||||||
|
|
||||||
run('node', ['scripts/build-android.mjs']);
|
run('node', ['scripts/build-android.mjs']);
|
||||||
run('npx', ['cap', 'sync', 'android']);
|
|
||||||
run(win ? 'gradlew.bat' : './gradlew', ['assembleRelease'], androidDir);
|
run(win ? 'gradlew.bat' : './gradlew', ['assembleRelease'], androidDir);
|
||||||
|
|
||||||
const relDir = path.join(androidDir, 'app/build/outputs/apk/release');
|
const relDir = path.join(androidDir, 'app/build/outputs/apk/release');
|
||||||
|
|||||||
Reference in New Issue
Block a user