mirror of
https://gitlab.com/vyla-entertainment/TV.git
synced 2026-11-14 20:17:22 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09ddbe6bae | |||
| 494d8e6835 | |||
| 1c3ffacccd | |||
| 44a6e3cef9 | |||
| 9c0c5bf17d | |||
| 01e532a6a5 |
@@ -1,3 +1,10 @@
|
|||||||
|
// Currently active sources reported on 6:18:22 PM PST 9/13/2026
|
||||||
|
|
||||||
|
// None Anime Tested: 936075
|
||||||
|
// Anime Tested: 37854
|
||||||
|
|
||||||
|
// Fshare/Fsonic Tested: 155 ( they don't have Michael )
|
||||||
|
|
||||||
export const SOURCES = [
|
export const SOURCES = [
|
||||||
{
|
{
|
||||||
key: '123anime',
|
key: '123anime',
|
||||||
@@ -232,7 +239,7 @@ export const SOURCES = [
|
|||||||
retries: 1,
|
retries: 1,
|
||||||
multiUrl: true,
|
multiUrl: true,
|
||||||
skipProxy: true,
|
skipProxy: true,
|
||||||
disabled: false,
|
disabled: false, // Enabled, but only returns streams when a manifest is provided
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { fetchJson } from '../utils/helpers.js';
|
||||||
|
|
||||||
|
// const PENGU_CONFIG = '%7B%22source_4khdhub%22%3A%22on%22%2C%22source_moviebox%22%3A%22on%22%2C%22source_moviesdrives%22%3A%22on%22%2C%22source_vaplayer%22%3A%22on%22%2C%22source_hdghartv%22%3A%22on%22%2C%22res_2160%22%3A%22on%22%2C%22res_1080%22%3A%22on%22%2C%22res_720%22%3A%22on%22%2C%22disable_direct%22%3A%22on%22%7D';
|
||||||
|
// const BASE_URL = `https://pengu.uk/${PENGU_CONFIG}`;
|
||||||
|
|
||||||
async function getImdbId(tmdbApiKey, tmdbId, s, e) {
|
async function getImdbId(tmdbApiKey, tmdbId, s, e) {
|
||||||
const key = tmdbApiKey;
|
const key = tmdbApiKey;
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// im being salty
|
||||||
|
|
||||||
import { USER_AGENT, fetchJson } from '../utils/helpers.js';
|
import { USER_AGENT, fetchJson } from '../utils/helpers.js';
|
||||||
|
|
||||||
const C_ARRAY = [
|
const C_ARRAY = [
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
val watchLaterItems by libraryManager.watchLater.collectAsState()
|
val watchLaterItems by libraryManager.watchLater.collectAsState()
|
||||||
val favoriteItems by libraryManager.favorites.collectAsState()
|
val favoriteItems by libraryManager.favorites.collectAsState()
|
||||||
|
val historyItems by libraryManager.history.collectAsState()
|
||||||
|
|
||||||
val sidebarFocusRequester = remember { FocusRequester() }
|
val sidebarFocusRequester = remember { FocusRequester() }
|
||||||
val contentFocusRequester = remember { FocusRequester() }
|
val contentFocusRequester = remember { FocusRequester() }
|
||||||
@@ -63,6 +65,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
if (playingItem == null) {
|
if (playingItem == null) {
|
||||||
Sidebar(
|
Sidebar(
|
||||||
currentDestination = currentDestination,
|
currentDestination = currentDestination,
|
||||||
|
hasHistory = historyItems.isNotEmpty(),
|
||||||
hasWatchLater = watchLaterItems.isNotEmpty(),
|
hasWatchLater = watchLaterItems.isNotEmpty(),
|
||||||
hasFavorites = favoriteItems.isNotEmpty(),
|
hasFavorites = favoriteItems.isNotEmpty(),
|
||||||
onNavigate = { dest ->
|
onNavigate = { dest ->
|
||||||
@@ -81,10 +84,12 @@ 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,
|
||||||
@@ -95,6 +100,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
mode = currentDestination,
|
mode = currentDestination,
|
||||||
onOpenDetail = { item -> activeDetailItem = item },
|
onOpenDetail = { item -> activeDetailItem = item },
|
||||||
onPlay = { item ->
|
onPlay = { item ->
|
||||||
|
libraryManager.addHistory(item)
|
||||||
playingItem = item
|
playingItem = item
|
||||||
playingEpisode = null
|
playingEpisode = null
|
||||||
},
|
},
|
||||||
@@ -115,6 +121,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
NavDestination.HISTORY -> {
|
||||||
|
LibraryScreen(
|
||||||
|
title = "Watch History",
|
||||||
|
items = historyItems,
|
||||||
|
onOpenDetail = { item -> activeDetailItem = item },
|
||||||
|
emptyMessage = "Nothing watched yet. Press play on a title and it shows up here."
|
||||||
|
)
|
||||||
|
}
|
||||||
NavDestination.WATCH_LATER -> {
|
NavDestination.WATCH_LATER -> {
|
||||||
LibraryScreen(
|
LibraryScreen(
|
||||||
title = "Watch Later",
|
title = "Watch Later",
|
||||||
@@ -144,6 +158,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
onToggleWatchLater = { libraryManager.toggle("wl", it) },
|
onToggleWatchLater = { libraryManager.toggle("wl", it) },
|
||||||
onToggleFavorite = { libraryManager.toggle("fav", it) },
|
onToggleFavorite = { libraryManager.toggle("fav", it) },
|
||||||
onPlay = { itm, ep ->
|
onPlay = { itm, ep ->
|
||||||
|
libraryManager.addHistory(itm)
|
||||||
playingItem = itm
|
playingItem = itm
|
||||||
playingEpisode = ep
|
playingEpisode = ep
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -308,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()
|
||||||
@@ -316,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,21 @@ class LibraryManager private constructor(context: Context) {
|
|||||||
private val _favorites = MutableStateFlow<List<MediaItem>>(emptyList())
|
private val _favorites = MutableStateFlow<List<MediaItem>>(emptyList())
|
||||||
val favorites: StateFlow<List<MediaItem>> = _favorites.asStateFlow()
|
val favorites: StateFlow<List<MediaItem>> = _favorites.asStateFlow()
|
||||||
|
|
||||||
|
private val _history = MutableStateFlow<List<MediaItem>>(emptyList())
|
||||||
|
val history: StateFlow<List<MediaItem>> = _history.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun addHistory(item: MediaItem) {
|
||||||
|
val current = _history.value.toMutableList()
|
||||||
|
current.removeAll { it.id == item.id && it.mediaType == item.mediaType }
|
||||||
|
current.add(0, item)
|
||||||
|
_history.value = current.take(100)
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
private fun load() {
|
private fun load() {
|
||||||
val raw = prefs.getString("vyla.lib", null)
|
val raw = prefs.getString("vyla.lib", null)
|
||||||
if (raw.isNullOrEmpty()) return
|
if (raw.isNullOrEmpty()) return
|
||||||
@@ -41,6 +52,7 @@ class LibraryManager private constructor(context: Context) {
|
|||||||
val root = JSONObject(raw)
|
val root = JSONObject(raw)
|
||||||
_watchLater.value = parseList(root.optJSONArray("wl"))
|
_watchLater.value = parseList(root.optJSONArray("wl"))
|
||||||
_favorites.value = parseList(root.optJSONArray("fav"))
|
_favorites.value = parseList(root.optJSONArray("fav"))
|
||||||
|
_history.value = parseList(root.optJSONArray("hist"))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
@@ -51,6 +63,7 @@ class LibraryManager private constructor(context: Context) {
|
|||||||
val root = JSONObject()
|
val root = JSONObject()
|
||||||
root.put("wl", serializeList(_watchLater.value))
|
root.put("wl", serializeList(_watchLater.value))
|
||||||
root.put("fav", serializeList(_favorites.value))
|
root.put("fav", serializeList(_favorites.value))
|
||||||
|
root.put("hist", serializeList(_history.value))
|
||||||
prefs.edit().putString("vyla.lib", root.toString()).apply()
|
prefs.edit().putString("vyla.lib", root.toString()).apply()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ enum class NavDestination(val key: String, val title: String, val icon: ImageVec
|
|||||||
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),
|
ANIME("anime", "Anime", Icons.Default.AutoAwesome),
|
||||||
|
HISTORY("history", "History", Icons.Default.History),
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -59,6 +60,7 @@ enum class NavDestination(val key: String, val title: String, val icon: ImageVec
|
|||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
fun Sidebar(
|
fun Sidebar(
|
||||||
currentDestination: NavDestination,
|
currentDestination: NavDestination,
|
||||||
|
hasHistory: Boolean,
|
||||||
hasWatchLater: Boolean,
|
hasWatchLater: Boolean,
|
||||||
hasFavorites: Boolean,
|
hasFavorites: Boolean,
|
||||||
onNavigate: (NavDestination) -> Unit,
|
onNavigate: (NavDestination) -> Unit,
|
||||||
@@ -148,6 +150,7 @@ fun Sidebar(
|
|||||||
) {
|
) {
|
||||||
NavDestination.values().forEach { destination ->
|
NavDestination.values().forEach { destination ->
|
||||||
val shouldShow = when (destination) {
|
val shouldShow = when (destination) {
|
||||||
|
NavDestination.HISTORY -> hasHistory || currentDestination == NavDestination.HISTORY
|
||||||
NavDestination.WATCH_LATER -> hasWatchLater || currentDestination == NavDestination.WATCH_LATER
|
NavDestination.WATCH_LATER -> hasWatchLater || currentDestination == NavDestination.WATCH_LATER
|
||||||
NavDestination.FAVORITES -> hasFavorites || currentDestination == NavDestination.FAVORITES
|
NavDestination.FAVORITES -> hasFavorites || currentDestination == NavDestination.FAVORITES
|
||||||
else -> true
|
else -> true
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ fun VideoTintSampler(active: Boolean, viewProvider: () -> View?) {
|
|||||||
bitmap.getPixels(pixels, 0, GRID_W, 0, 0, GRID_W, GRID_H)
|
bitmap.getPixels(pixels, 0, GRID_W, 0, 0, GRID_W, GRID_H)
|
||||||
VideoTintState.frame = VideoFrameColors(pixels.copyOf(), GRID_W, GRID_H)
|
VideoTintState.frame = VideoFrameColors(pixels.copyOf(), GRID_W, GRID_H)
|
||||||
}
|
}
|
||||||
delay(400)
|
delay(700)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ fun BackdropTintSampler(url: String?) {
|
|||||||
}
|
}
|
||||||
val request = ImageRequest.Builder(context)
|
val request = ImageRequest.Builder(context)
|
||||||
.data(url)
|
.data(url)
|
||||||
.size(96, 54)
|
.size(48, 27)
|
||||||
.allowHardware(false)
|
.allowHardware(false)
|
||||||
.build()
|
.build()
|
||||||
val result = context.imageLoader.execute(request)
|
val result = context.imageLoader.execute(request)
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ import kotlinx.coroutines.withTimeoutOrNull
|
|||||||
import tv.vyla.app.ui.components.ContentFocusBridge
|
import tv.vyla.app.ui.components.ContentFocusBridge
|
||||||
import tv.vyla.app.ui.components.BackdropTintSampler
|
import tv.vyla.app.ui.components.BackdropTintSampler
|
||||||
import tv.vyla.app.ui.components.videoTint
|
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(
|
||||||
|
|
||||||
@@ -93,17 +97,24 @@ fun BrowseScreen(
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val cardRequesters = remember { mutableMapOf<String, FocusRequester>() }
|
val cardRequesters = remember { mutableMapOf<String, FocusRequester>() }
|
||||||
var lastFocusedKey by remember { mutableStateOf<String?>(null) }
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun pinAmbientToHero() {
|
||||||
|
heroItem?.let { ambientUrl = it.ambientBackdropUrl }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun loadMoreRows(count: Int) {
|
suspend fun loadMoreRows(count: Int) {
|
||||||
if (isLoadingMore) return
|
if (isLoadingMore || rows.size >= 48) return
|
||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
val generation = loadGeneration
|
val generation = loadGeneration
|
||||||
val activeMode = currentMode
|
val activeMode = currentMode
|
||||||
@@ -147,6 +158,7 @@ suspend fun loadMoreRows(count: Int) {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
@@ -191,6 +203,8 @@ LaunchedEffect(mode) {
|
|||||||
rows.clear()
|
rows.clear()
|
||||||
rowIdx = 0
|
rowIdx = 0
|
||||||
heroItem = null
|
heroItem = null
|
||||||
|
ambientUrl = null
|
||||||
|
lastRowIndex = -1
|
||||||
heroLogoUrl = null
|
heroLogoUrl = null
|
||||||
movieGenres.clear()
|
movieGenres.clear()
|
||||||
tvGenres.clear()
|
tvGenres.clear()
|
||||||
@@ -215,22 +229,11 @@ LaunchedEffect(mode) {
|
|||||||
|
|
||||||
BackdropTintSampler(heroItem?.ambientBackdropUrl)
|
BackdropTintSampler(heroItem?.ambientBackdropUrl)
|
||||||
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
|
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
|
||||||
Crossfade(
|
AmbientBackdrop(
|
||||||
targetState = heroItem?.ambientBackdropUrl,
|
url = ambientUrl,
|
||||||
animationSpec = tween(700),
|
modifier = Modifier.fillMaxSize(),
|
||||||
label = "ambient_bg"
|
durationMs = 700
|
||||||
) { url ->
|
)
|
||||||
if (!url.isNullOrEmpty()) {
|
|
||||||
AsyncImage(
|
|
||||||
model = url,
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.blur(50.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -254,6 +257,8 @@ LaunchedEffect(mode) {
|
|||||||
playButtonFocusRequester = playButtonFocusRequester,
|
playButtonFocusRequester = playButtonFocusRequester,
|
||||||
onFocusedTop = {
|
onFocusedTop = {
|
||||||
lastFocusedKey = null
|
lastFocusedKey = null
|
||||||
|
lastRowIndex = -1
|
||||||
|
pinAmbientToHero()
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
}
|
}
|
||||||
@@ -268,9 +273,8 @@ LaunchedEffect(mode) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
items(rows) { row ->
|
itemsIndexed(rows, key = { i, row -> "$i:${row.title}" }) { rowIndex, row ->
|
||||||
val rowState = rememberLazyListState()
|
val rowState = rememberLazyListState()
|
||||||
val rowIndex = rows.indexOf(row)
|
|
||||||
Column(modifier = Modifier.padding(vertical = 10.dp)) {
|
Column(modifier = Modifier.padding(vertical = 10.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = row.title,
|
text = row.title,
|
||||||
@@ -284,10 +288,9 @@ LaunchedEffect(mode) {
|
|||||||
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 index = row.items.indexOf(item)
|
val isFirstInRow = index == 0
|
||||||
val isFirstInRow = row.items.firstOrNull() == item
|
val cardKey = "$rowIndex:${item.id}"
|
||||||
val cardKey = "${row.title}:${item.id}"
|
|
||||||
val cardRequester = cardRequesters.getOrPut(cardKey) { FocusRequester() }
|
val cardRequester = cardRequesters.getOrPut(cardKey) { FocusRequester() }
|
||||||
MediaCard(
|
MediaCard(
|
||||||
item = item,
|
item = item,
|
||||||
@@ -302,11 +305,16 @@ LaunchedEffect(mode) {
|
|||||||
index,
|
index,
|
||||||
-(viewportWidth / 2 - 68)
|
-(viewportWidth / 2 - 68)
|
||||||
)
|
)
|
||||||
val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset
|
}
|
||||||
listState.animateScrollToItem(
|
if (lastRowIndex != rowIndex) {
|
||||||
rowIndex + 1,
|
lastRowIndex = rowIndex
|
||||||
-(viewportHeight / 2 - 100)
|
coroutineScope.launch {
|
||||||
)
|
val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset
|
||||||
|
listState.animateScrollToItem(
|
||||||
|
rowIndex + 1,
|
||||||
|
-(viewportHeight / 2 - 100)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.focusRequester(cardRequester).onKeyEvent { event ->
|
modifier = Modifier.focusRequester(cardRequester).onKeyEvent { event ->
|
||||||
@@ -345,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)
|
||||||
@@ -503,7 +504,10 @@ private fun HeroPillButton(
|
|||||||
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))
|
||||||
.then(
|
.then(
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -141,7 +147,6 @@ fun DetailScreen(
|
|||||||
if (d != null && d.seasons.isNotEmpty()) {
|
if (d != null && d.seasons.isNotEmpty()) {
|
||||||
val initialSeason = d.seasons.firstOrNull { it.seasonNumber == 1 } ?: d.seasons.first()
|
val initialSeason = d.seasons.firstOrNull { it.seasonNumber == 1 } ?: d.seasons.first()
|
||||||
selectedSeason = initialSeason
|
selectedSeason = initialSeason
|
||||||
seasonEpisodes = TmdbApi.getSeasonEpisodes(item.id, initialSeason.seasonNumber)
|
|
||||||
}
|
}
|
||||||
delay(150)
|
delay(150)
|
||||||
try {
|
try {
|
||||||
@@ -190,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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -952,15 +938,27 @@ private fun DetailTrailerCard(
|
|||||||
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")
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
val tinyRequest = remember(trailer.key) {
|
||||||
|
ImageRequest.Builder(context)
|
||||||
|
.data(trailer.thumbnailUrl)
|
||||||
|
.size(32, 18)
|
||||||
|
.allowHardware(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
Box(
|
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()
|
||||||
@@ -971,11 +969,35 @@ private fun DetailTrailerCard(
|
|||||||
indication = null
|
indication = null
|
||||||
) { onClick() }
|
) { onClick() }
|
||||||
) {
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = tinyRequest,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
filterQuality = FilterQuality.Medium,
|
||||||
|
modifier = Modifier.matchParentSize()
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
0.0f to Color(0x26000000),
|
||||||
|
1.0f to Color(0x80000000)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
Column {
|
Column {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.aspectRatio(16f / 9f)
|
.aspectRatio(16f / 9f)
|
||||||
|
.verticalFadeMask(
|
||||||
|
0f to 1f,
|
||||||
|
0.55f to 1f,
|
||||||
|
0.72f to 0.72f,
|
||||||
|
0.86f to 0.30f,
|
||||||
|
1f to 0f
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = trailer.thumbnailUrl,
|
model = trailer.thumbnailUrl,
|
||||||
@@ -998,62 +1020,43 @@ private fun DetailTrailerCard(
|
|||||||
modifier = Modifier.size(26.dp)
|
modifier = Modifier.size(26.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(40.dp)
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.background(
|
|
||||||
Brush.verticalGradient(
|
|
||||||
0.0f to Color.Transparent,
|
|
||||||
1.0f to Color(0xE6101016)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(76.dp)
|
.padding(start = 14.dp, end = 14.dp, bottom = 14.dp)
|
||||||
) {
|
) {
|
||||||
AsyncImage(
|
Text(
|
||||||
model = trailer.thumbnailUrl,
|
text = trailer.type.uppercase(),
|
||||||
contentDescription = null,
|
color = Color(0xB3FFFFFF),
|
||||||
contentScale = ContentScale.Crop,
|
fontSize = 11.sp,
|
||||||
modifier = Modifier
|
fontWeight = FontWeight.SemiBold,
|
||||||
.fillMaxSize()
|
letterSpacing = 1.2.sp
|
||||||
.blur(36.dp)
|
|
||||||
)
|
)
|
||||||
Box(
|
Text(
|
||||||
modifier = Modifier
|
text = trailer.name,
|
||||||
.fillMaxSize()
|
color = Color.White,
|
||||||
.background(
|
fontSize = 15.sp,
|
||||||
Brush.verticalGradient(
|
fontWeight = FontWeight.Bold,
|
||||||
0.0f to Color(0xE6101016),
|
maxLines = 1,
|
||||||
0.35f to Color(0x99101016),
|
overflow = TextOverflow.Ellipsis,
|
||||||
1.0f to Color(0xF2101016)
|
modifier = Modifier.padding(top = 6.dp)
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
Column(
|
Row(
|
||||||
modifier = Modifier
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
.fillMaxSize()
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Icon(
|
||||||
text = trailer.name,
|
imageVector = Icons.Default.PlayArrow,
|
||||||
color = Color(0xFFF2F2F5),
|
contentDescription = null,
|
||||||
fontSize = 14.sp,
|
tint = Color(0xCCFFFFFF),
|
||||||
fontWeight = FontWeight.SemiBold,
|
modifier = Modifier.size(14.dp)
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "▶ ${trailer.type}",
|
text = trailer.site,
|
||||||
color = Color(0xFFD0D0DA),
|
color = Color(0xCCFFFFFF),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
modifier = Modifier.padding(start = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,32 +27,23 @@ import androidx.compose.ui.draw.blur
|
|||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import tv.vyla.app.ui.components.AmbientBackdrop
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(
|
fun LibraryScreen(
|
||||||
title: String,
|
title: String,
|
||||||
items: List<MediaItem>,
|
items: List<MediaItem>,
|
||||||
onOpenDetail: (MediaItem) -> Unit,
|
onOpenDetail: (MediaItem) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
emptyMessage: String = "Nothing here yet. Open a title, press the ⋮ button and add it."
|
||||||
) {
|
) {
|
||||||
var ambientUrl by remember(items) { mutableStateOf(items.firstOrNull()?.ambientBackdropUrl) }
|
var ambientUrl by remember(items) { mutableStateOf(items.firstOrNull()?.ambientBackdropUrl) }
|
||||||
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
|
Box(modifier = modifier.fillMaxSize().background(VylaBackground)) {
|
||||||
Crossfade(
|
AmbientBackdrop(
|
||||||
targetState = ambientUrl,
|
url = ambientUrl,
|
||||||
animationSpec = tween(700),
|
modifier = Modifier.fillMaxSize(),
|
||||||
label = "library_ambient"
|
durationMs = 700
|
||||||
) { url ->
|
)
|
||||||
if (!url.isNullOrEmpty()) {
|
|
||||||
AsyncImage(
|
|
||||||
model = url,
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.blur(50.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -96,7 +87,7 @@ fun LibraryScreen(
|
|||||||
contentAlignment = androidx.compose.ui.Alignment.TopStart
|
contentAlignment = androidx.compose.ui.Alignment.TopStart
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Nothing here yet. Open a title, press the ⋮ button and add it.",
|
text = emptyMessage,
|
||||||
color = VylaTextMuted,
|
color = VylaTextMuted,
|
||||||
fontSize = 17.sp
|
fontSize = 17.sp
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -178,17 +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
|
||||||
|
if (areControlsVisible || nearEnd) {
|
||||||
|
if (pos != currentPositionMs) currentPositionMs = pos
|
||||||
|
val buffered = exoPlayer.bufferedPosition.coerceAtLeast(0L)
|
||||||
|
if (buffered != bufferedPositionMs) bufferedPositionMs = buffered
|
||||||
|
}
|
||||||
|
val currentSec = pos / 1000.0
|
||||||
val seg = segments.firstOrNull { s ->
|
val seg = segments.firstOrNull { s ->
|
||||||
val endSec = s.end
|
val endSec = s.end
|
||||||
currentSec >= s.start && (endSec == null || currentSec < endSec)
|
currentSec >= s.start && (endSec == null || currentSec < endSec)
|
||||||
}
|
}
|
||||||
activeSegment = seg
|
if (seg != activeSegment) activeSegment = seg
|
||||||
delay(300)
|
delay(if (areControlsVisible) 300L else 600L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ 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 androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import tv.vyla.app.ui.components.SearchIcons
|
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
|
||||||
|
|
||||||
@@ -131,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()
|
||||||
@@ -192,7 +183,7 @@ 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) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -566,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
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tv",
|
"name": "tv",
|
||||||
"version": "1.0.4",
|
"version": "1.0.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tv",
|
"name": "tv",
|
||||||
"version": "1.0.4",
|
"version": "1.0.6",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vyla-entertainment/sdk": "^1.14.0",
|
"@vyla-entertainment/sdk": "^1.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tv",
|
"name": "tv",
|
||||||
"version": "1.0.4",
|
"version": "1.0.6",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Vyla TV",
|
"name": "Vyla TV",
|
||||||
"version": "1.0.3",
|
"version": "1.0.5",
|
||||||
"package": "Vyla-TV_1.0.3.apk",
|
"package": "Vyla-TV_1.0.5.apk",
|
||||||
"releaseDate": "2026-10-05T18:09:19.183Z"
|
"releaseDate": "2026-10-05T22:49:51.172Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user