Consolidate UI palette into FluxaColors tokens

Replace scattered color literals (Netflix-red E50914/E53935 progress and
accent fallbacks, drifting dark surface tones) with a single FluxaColors
object; AppTheme and ThemeHelper now source from the same tokens. Accent
fallback unifies on the theme ember E85D3F.
This commit is contained in:
KhooLy 2026-07-02 00:34:30 +03:00
parent 9384e1257b
commit 663c1154f1
18 changed files with 3577 additions and 830 deletions

View file

@ -18,17 +18,17 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFFE85D3F),
primary = FluxaColors.accent,
secondary = Color(0xFF7A8799),
tertiary = Color(0xFFF0C674),
background = Color(0xFF090B10),
surface = Color(0xFF12161D),
surfaceVariant = Color(0xFF1B212B),
onPrimary = Color(0xFF090B10),
onSecondary = Color(0xFFF4F1EA),
onTertiary = Color(0xFF090B10),
onBackground = Color(0xFFF4F1EA),
onSurface = Color(0xFFF4F1EA)
tertiary = FluxaColors.accentGold,
background = FluxaColors.background,
surface = FluxaColors.surface,
surfaceVariant = FluxaColors.surfaceRaised,
onPrimary = FluxaColors.background,
onSecondary = FluxaColors.textPrimary,
onTertiary = FluxaColors.background,
onBackground = FluxaColors.textPrimary,
onSurface = FluxaColors.textPrimary
)
private val AppTypography = Typography(

View file

@ -0,0 +1,725 @@
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.common.ReleaseDateUtils
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.domain.discovery.*
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.foundation.Image
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.*
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import coil3.request.ImageRequest
import coil3.request.transformations
import com.fluxa.app.R
import java.text.SimpleDateFormat
import java.util.*
@Composable
fun DetailHeaderContentOfficial(
detail: MetaDetail?,
selectedEpisode: Video?,
isInWatchlist: Boolean,
feedback: Boolean?,
lang: String,
onBack: () -> Unit,
onToggleWatchlist: () -> Unit,
onFeedback: (Boolean) -> Unit,
onPlayClick: () -> Unit
) {
val deviceType = LocalDeviceType.current
val horizontalPadding = if (deviceType == DeviceType.TV) 58.dp else 16.dp
val playButtonFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (deviceType != DeviceType.TV) return@LaunchedEffect
repeat(15) {
try {
playButtonFocusRequester.requestFocus()
return@LaunchedEffect
} catch (e: Exception) {
withFrameNanos {}
}
}
}
Column(modifier = Modifier.padding(start = horizontalPadding, top = if (deviceType == DeviceType.TV) 40.dp else 24.dp).fillMaxWidth()) {
// [LOGO]
var logoLoadFailed by remember { mutableStateOf(false) }
val resolvedLogo = detail?.logo
if (!resolvedLogo.isNullOrEmpty() && !logoLoadFailed) {
val context = LocalContext.current
val logoRequest = remember(context, resolvedLogo) {
ImageRequest.Builder(context)
.data(resolvedLogo)
.memoryCacheKey("detail-logo:$resolvedLogo")
.diskCacheKey(resolvedLogo)
.transformations(TrimTransparentEdgesTransformation())
.build()
}
AsyncImage(
model = logoRequest,
contentDescription = null,
modifier = Modifier.height(if (deviceType == DeviceType.TV) 130.dp else 80.dp).widthIn(max = 500.dp),
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
onError = { logoLoadFailed = true }
)
} else {
Text(
text = detail?.name?.uppercase() ?: stringResource(R.string.app_name).uppercase(),
style = if (deviceType == DeviceType.TV) MaterialTheme.typography.displayLarge else MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Black,
color = Color.White,
letterSpacing = (-2).sp,
lineHeight = if(deviceType == DeviceType.TV) 60.sp else 44.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
// [ROW 1: OFFICIAL RATINGS]
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
detail?.ratings?.forEach { r -> OfficialRatingBadge(r.source, r.value.toString()) }
}
Spacer(modifier = Modifier.height(16.dp))
val metaParts = mutableListOf<String>()
detail?.releaseInfo?.let { metaParts.add(it) }
if (detail?.type == "series") {
detail.seasonsCount?.let { metaParts.add("${it} ${AppStrings.t(lang, "auto.season")}") }
}
detail?.runtime?.let { metaParts.add(formatRuntimeLabel(it, lang)) }
Row(verticalAlignment = Alignment.CenterVertically) {
metaParts.forEachIndexed { i, part ->
Text(text = part, color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Bold)
if (i < metaParts.size - 1) {
Text(text = " ", color = Color.White.copy(0.4f), fontSize = 15.sp, fontWeight = FontWeight.Black, modifier = Modifier.padding(horizontal = 6.dp))
}
}
val platforms = detail?.platforms.orEmpty()
if (platforms.isNotEmpty()) {
Text(text = " ", color = Color.White.copy(0.4f), fontSize = 15.sp, fontWeight = FontWeight.Black, modifier = Modifier.padding(horizontal = 6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
platforms.forEach { logo ->
AsyncImage(
model = logo,
contentDescription = null,
modifier = Modifier.height(16.dp),
contentScale = ContentScale.Fit,
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(Color.White)
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
val scheduleLabel = remember(detail?.id, detail?.videos, lang) { releaseScheduleLabel(detail, lang) }
scheduleLabel?.let {
Text(
text = it,
color = Color(0xFF45D483),
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(12.dp))
}
// [ROW 3: TAGS]
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.horizontalScroll(rememberScrollState())) {
detail?.ageRating?.takeIf { it.isNotBlank() }?.let { ageRating ->
Box(modifier = Modifier.border(1.dp, Color.White.copy(0.4f), RoundedCornerShape(2.dp)).padding(horizontal = 6.dp, vertical = 2.dp)) {
Text(text = ageRating, color = Color.White, fontSize = 11.sp, fontWeight = FontWeight.Bold)
}
Spacer(Modifier.width(12.dp))
}
detail?.genres?.forEach { genre ->
Box(modifier = Modifier.padding(end = 8.dp).background(Color(0xFF1A1A1A), RoundedCornerShape(4.dp)).padding(horizontal = 10.dp, vertical = 4.dp)) {
Text(text = genre, color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// [ACTION BUTTONS]
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
val playButtonText = AppStrings.t(lang, "auto.play")
Surface(
onClick = onPlayClick,
modifier = Modifier
.height(48.dp)
.then(
if (deviceType == DeviceType.TV) Modifier.widthIn(min = 140.dp, max = 240.dp)
else Modifier.wrapContentWidth() // Hug text on mobile
)
.focusRequester(playButtonFocusRequester)
.run { if (deviceType == DeviceType.Mobile) clickable { onPlayClick() } else this },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
colors = ClickableSurfaceDefaults.colors(containerColor = Color.White, focusedContainerColor = Color.White, contentColor = Color.Black, focusedContentColor = Color.Black)
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxHeight().padding(horizontal = if(deviceType == DeviceType.TV) 24.dp else 16.dp)) {
Text(
text = playButtonText,
fontWeight = FontWeight.Black,
fontSize = 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)
}
}
ActionCircleBtn(if (isInWatchlist) FluxaIcons.Check else FluxaIcons.Add, onToggleWatchlist, tint = if(isInWatchlist) Color(0xFF00E054) else Color.White)
ActionCircleBtn(FluxaIcons.ThumbUp, { onFeedback(true) }, tint = if(feedback == true) Color.White else Color.White.copy(0.35f))
ActionCircleBtn(FluxaIcons.ThumbDown, { onFeedback(false) }, tint = if(feedback == false) Color.White else Color.White.copy(0.35f))
}
}
}
@Composable
fun OfficialRatingBadge(source: String, text: String) {
val logoRes = remember(source) {
when (source.lowercase().trim()) {
"imdb" -> R.drawable.imdb_logo
"tmdb" -> R.drawable.ic_tmdb
"trakt" -> R.drawable.ic_trakt
"simkl" -> R.drawable.ic_simkl
else -> null
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier.padding(end = 24.dp)
) {
if (logoRes != null) {
Image(
painter = painterResource(id = logoRes),
contentDescription = null,
modifier = Modifier.height(16.dp).widthIn(max = 52.dp),
contentScale = ContentScale.Fit
)
} else {
Box(modifier = Modifier.size(24.dp).background(Color.White.copy(0.1f), RoundedCornerShape(4.dp)), contentAlignment = Alignment.Center) {
Text(text = source.take(1).uppercase(), color = Color.White, fontSize = 10.sp, fontWeight = FontWeight.Black)
}
Spacer(Modifier.width(8.dp))
Text(
text = source,
color = Color.White.copy(alpha = 0.65f),
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 96.dp)
)
}
Spacer(Modifier.width(6.dp))
Text(
text = text,
color = Color.White,
fontWeight = FontWeight.Black,
fontSize = 17.sp
)
}
}
@Composable
fun ActionCircleBtn(icon: ImageVector, onClick: () -> Unit, tint: Color = Color.White) {
var isFocused by remember { mutableStateOf(false) }
val deviceType = LocalDeviceType.current
Surface(
onClick = onClick,
modifier = Modifier
.size(52.dp)
.onFocusChanged { isFocused = it.isFocused }
.run { if (deviceType == DeviceType.Mobile) clickable { onClick() } else this },
shape = ClickableSurfaceDefaults.shape(CircleShape),
colors = ClickableSurfaceDefaults.colors(containerColor = Color.White.copy(0.1f), focusedContainerColor = Color.White)
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Icon(icon, null, tint = if (isFocused) Color.Black else tint, modifier = Modifier.size(26.dp)) }
}
}
@Composable
fun SeasonPill(seasonNumber: Int, isSelected: Boolean, accentColor: Color, lang: String, onClick: () -> Unit) {
var isFocused by remember { mutableStateOf(false) }
val deviceType = LocalDeviceType.current
val containerColor = if (isSelected) Color.White else if (isFocused) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.05f)
val contentColor = if (isSelected) Color.Black else Color.White
val seasonName = if (seasonNumber == 0) (AppStrings.t(lang, "auto.specials")) else "${AppStrings.t(lang, "auto.season")} $seasonNumber"
Surface(
onClick = onClick,
modifier = Modifier.width(140.dp).height(50.dp).onFocusChanged { isFocused = it.isFocused }.run { if (deviceType == DeviceType.Mobile) clickable { onClick() } else this },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)),
colors = ClickableSurfaceDefaults.colors(containerColor = containerColor, contentColor = contentColor, focusedContainerColor = Color.White, focusedContentColor = Color.Black)
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = seasonName, fontSize = 14.sp, fontWeight = FontWeight.Bold)
}
}
}
@Composable
fun EpisodeCard(
episode: Video,
isSelected: Boolean,
isWatched: Boolean = false,
progress: Float = 0f,
durationLabel: String? = null,
accentColor: Color,
lang: String,
onFocus: () -> Unit,
onClick: () -> Unit,
onDownloadClick: (() -> Unit)? = null
) {
var isFocused by remember { mutableStateOf(false) }
var pressStartTime by remember { mutableStateOf(0L) }
val deviceType = LocalDeviceType.current
val isUpcoming = detailIsUpcoming(episode.released)
val thumbnailRequest = rememberEpisodeThumbnailRequest(episode)
val runtimeText = durationLabel?.takeIf { it.isNotBlank() }?.let { formatRuntimeLabel(it, lang) }
Column(
modifier = Modifier
.width(300.dp)
.onFocusChanged { if (it.isFocused) { isFocused = true; onFocus() } else isFocused = false }
.onPreviewKeyEvent { event ->
if (onDownloadClick == null || (event.key != Key.DirectionCenter && event.key != Key.Enter)) return@onPreviewKeyEvent false
when (event.type) {
KeyEventType.KeyDown -> {
if (pressStartTime == 0L) pressStartTime = System.currentTimeMillis()
false
}
KeyEventType.KeyUp -> {
val heldMs = System.currentTimeMillis() - pressStartTime
pressStartTime = 0L
if (heldMs >= 500L) { onDownloadClick.invoke(); true } else false
}
else -> false
}
}
) {
Surface(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.height(170.dp)
.run { if (deviceType == DeviceType.Mobile) clickable { onClick() } else this },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
border = ClickableSurfaceDefaults.border(focusedBorder = Border(androidx.compose.foundation.BorderStroke(3.dp, Color.White))),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
) {
var thumbnailFailed by remember(thumbnailRequest) { mutableStateOf(false) }
Box {
if (thumbnailRequest != null && !thumbnailFailed) {
AsyncImage(
model = thumbnailRequest,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().alpha(if (isUpcoming) 0.4f else if (isWatched) 0.45f else 1.0f),
onState = { state -> if (state is AsyncImagePainter.State.Error) thumbnailFailed = true }
)
}
else { Box(modifier = Modifier.fillMaxSize().background(Color.White.copy(0.05f)), contentAlignment = Alignment.Center) { Icon(FluxaIcons.Movie, null, tint = Color.White.copy(0.1f), modifier = Modifier.size(48.dp)) } }
if (isWatched) {
Box(
modifier = Modifier
.align(Alignment.Center)
.size(44.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.38f)),
contentAlignment = Alignment.Center
) {
Icon(FluxaIcons.CheckCircle, null, tint = Color.White, modifier = Modifier.size(24.dp))
}
}
}
}
if (progress > 0f || isWatched) {
Spacer(Modifier.height(8.dp))
EpisodeProgressBar(progress = if (isWatched) 1f else progress, accentColor = accentColor)
}
Spacer(Modifier.height(10.dp))
Text(
text = "${episode.number ?: 0}. ${episode.name.orEmpty()}",
color = if (isFocused) Color.White else Color.White.copy(alpha = 0.88f),
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
runtimeText?.let {
Spacer(Modifier.height(4.dp))
Text(
text = it,
color = Color.White.copy(alpha = 0.62f),
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
}
internal fun sortedDetailEpisodes(episodes: List<Video>, sort: String): List<Video> {
val comparator = when (sort.substringBefore("_")) {
"rating" -> compareBy<Video> { it.rating?.toDoubleOrNull() ?: -1.0 }
"released" -> compareBy { it.released.orEmpty() }
else -> compareBy<Video>({ it.number ?: Int.MAX_VALUE }, { it.name.orEmpty() })
}
return if (sort.endsWith("_desc")) episodes.sortedWith(comparator.reversed()) else episodes.sortedWith(comparator)
}
@Composable
internal fun rememberEpisodeThumbnailRequest(episode: Video): ImageRequest? {
val context = LocalContext.current
val thumbnailUrl = remember(episode.thumbnail) {
episode.thumbnail?.trim()?.takeIf { it.isNotBlank() }
}
return remember(context, episode.id, thumbnailUrl) {
thumbnailUrl?.let { url ->
ImageRequest.Builder(context)
.data(url)
.memoryCacheKey("detail-episode:${episode.id}:$url")
.diskCacheKey(url)
.build()
}
}
}
internal fun episodeSortOptions(lang: String): List<Pair<String?, String>> = listOf(
"number_asc" to AppStrings.t(lang, "sort.episode_number_asc"),
"number_desc" to AppStrings.t(lang, "sort.episode_number_desc"),
"rating_asc" to AppStrings.t(lang, "sort.rating_asc"),
"rating_desc" to AppStrings.t(lang, "sort.rating_desc"),
"released_asc" to AppStrings.t(lang, "sort.release_date_asc"),
"released_desc" to AppStrings.t(lang, "sort.release_date_desc")
)
@Composable
fun EpisodeProgressBar(progress: Float, accentColor: Color) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(3.dp)
.clip(RoundedCornerShape(999.dp))
.background(Color.White.copy(alpha = 0.18f))
) {
Box(
modifier = Modifier
.fillMaxWidth(progress.coerceIn(0f, 1f))
.fillMaxHeight()
.background(FluxaColors.progressFill)
)
}
}
@Composable
fun TrailerCard(trailer: DetailTrailer, accentColor: Color, onPlay: (() -> Unit)? = null) {
val context = LocalContext.current
val deviceType = LocalDeviceType.current
val playableUrl = trailer.url.takeIf { it.isNotBlank() }
val handleClick = {
if (onPlay != null) {
onPlay()
} else if (playableUrl != null) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(playableUrl))) }
}
}
Surface(
onClick = handleClick,
modifier = Modifier
.width(260.dp)
.height(206.dp)
.run { if (deviceType == DeviceType.Mobile) clickable { handleClick() } else this },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = Color.White.copy(alpha = 0.08f),
contentColor = Color.White,
focusedContainerColor = accentColor,
focusedContentColor = Color.Black
),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f),
border = ClickableSurfaceDefaults.border(focusedBorder = Border(androidx.compose.foundation.BorderStroke(2.dp, accentColor)))
) {
Column(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(146.dp)
.background(Color.Black.copy(alpha = 0.34f))
) {
if (!trailer.thumbnail.isNullOrBlank()) {
AsyncImage(
model = trailer.thumbnail,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Box(
modifier = Modifier
.align(Alignment.Center)
.size(44.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.52f)),
contentAlignment = Alignment.Center
) {
Icon(FluxaIcons.PlayArrow, null, tint = Color.White, modifier = Modifier.size(24.dp))
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.height(60.dp)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Text(
text = trailer.type,
color = accentColor,
fontSize = 11.sp,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (trailer.title.isNotBlank() && !trailer.title.equals(trailer.type, ignoreCase = true)) {
Spacer(Modifier.height(4.dp))
Text(
text = trailer.title,
color = Color.White,
fontSize = 13.sp,
lineHeight = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@Composable
fun StreamCard(stream: StreamUiModel, accentColor: Color, onClick: () -> Unit) {
val deviceType = LocalDeviceType.current
val sourceName = stream.header
val rawTitle = stream.body
Surface(
onClick = onClick,
modifier = Modifier
.width(320.dp)
.height(172.dp)
.run { if (deviceType == DeviceType.Mobile) clickable { onClick() } else this },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
colors = ClickableSurfaceDefaults.colors(containerColor = Color.White.copy(0.08f), focusedContainerColor = Color.White, focusedContentColor = Color.Black, contentColor = Color.White),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.05f)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(12.dp)
) {
Text(
text = sourceName,
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
lineHeight = 15.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
rawTitle?.let {
Spacer(Modifier.height(5.dp))
AddonStreamBodyText(
text = it,
bodyMaxLines = 6,
contentColor = Color.White.copy(alpha = 0.72f)
)
}
}
}
}
@Composable
fun SourceFilterPill(name: String, isSelected: Boolean, onClick: () -> Unit) {
var isFocused by remember { mutableStateOf(false) }
val deviceType = LocalDeviceType.current
val containerColor = if (isSelected) Color.White else if (isFocused) Color.White.copy(alpha = 0.2f) else Color.White.copy(alpha = 0.05f)
val contentColor = if (isSelected) Color.Black else Color.White
Surface(
onClick = onClick,
modifier = Modifier
.onFocusChanged { isFocused = it.isFocused }
.run { if (deviceType == DeviceType.Mobile) clickable { onClick() } else this },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)),
colors = ClickableSurfaceDefaults.colors(containerColor = containerColor, contentColor = contentColor, focusedContainerColor = Color.White, focusedContentColor = Color.Black)
) {
Box(modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), contentAlignment = Alignment.Center) {
Text(text = name, fontSize = 13.sp, fontWeight = FontWeight.Bold)
}
}
}
@Composable
fun CastMemberCard(member: CastMember) {
val size = if (LocalDeviceType.current == DeviceType.TV) 100.dp else 80.dp
val cardWidth = if (LocalDeviceType.current == DeviceType.TV) 124.dp else 104.dp
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(cardWidth)) {
Box(modifier = Modifier.size(size).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))) {
if (member.profilePath != null) AsyncImage(member.profilePath, null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
else Icon(FluxaIcons.Person, null, modifier = Modifier.size(size/2).align(Alignment.Center), tint = Color.Gray)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = member.name,
color = Color.White,
fontSize = 12.sp,
lineHeight = 15.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
member.character?.takeIf { it.isNotEmpty() }?.let { character ->
Text(text = character, color = Color.White.copy(alpha = 0.5f), fontSize = 10.sp, textAlign = TextAlign.Center, maxLines = 1)
}
}
}
@Composable
fun SimilarContentCard(item: SimilarItemUiModel, accentColor: Color, onClick: () -> Unit) {
val deviceType = LocalDeviceType.current
Surface(
onClick = onClick,
modifier = Modifier
.width(150.dp)
.height(220.dp)
.run { if (deviceType == DeviceType.Mobile) clickable { onClick() } else this },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(8.dp)),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1.1f),
border = ClickableSurfaceDefaults.border(focusedBorder = Border(androidx.compose.foundation.BorderStroke(3.dp, Color.White)))
) { AsyncImage(model = item.poster, null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
}
internal fun detailIsUpcoming(dateStr: String?): Boolean {
return ReleaseDateUtils.isUpcoming(dateStr)
}
private fun parseReleaseDate(dateStr: String?): Date? {
if (dateStr.isNullOrBlank()) return null
return runCatching { SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(dateStr) }.getOrNull()
}
internal fun formatEpisodeReleaseDate(dateStr: String?, lang: String): String? {
val date = parseReleaseDate(dateStr) ?: return null
val locale = AppStrings.locale(lang)
return SimpleDateFormat("yyyy MMMM d", locale).format(date)
}
internal fun formatRuntimeLabel(runtimeLabel: String, lang: String): String {
return AppStrings.runtimeLabel(lang, runtimeLabel)
}
internal fun releaseScheduleLabel(detail: MetaDetail?, lang: String): String? {
val videos = detail?.videos.orEmpty()
if (videos.isEmpty()) return null
val normalizedStatus = detail?.status?.lowercase()?.trim().orEmpty()
val hasFutureEpisode = videos.any { video -> parseReleaseDate(video.released)?.after(Date()) == true }
val isFinished = normalizedStatus.contains("ended") ||
normalizedStatus.contains("canceled") ||
normalizedStatus.contains("cancelled")
val isActivelyReleasing = hasFutureEpisode ||
normalizedStatus.contains("returning") ||
normalizedStatus.contains("continuing") ||
normalizedStatus.contains("production") ||
normalizedStatus.contains("planned")
if (isFinished || !isActivelyReleasing) return null
val now = Date()
val futureDay = videos
.mapNotNull { parseReleaseDate(it.released) }
.filter { it.after(now) }
.minByOrNull { it.time }
?.let { Calendar.getInstance().apply { time = it }.get(Calendar.DAY_OF_WEEK) }
val dominantDay = futureDay ?: videos
.mapNotNull { parseReleaseDate(it.released) }
.groupingBy { date ->
Calendar.getInstance().apply { time = date }.get(Calendar.DAY_OF_WEEK)
}
.eachCount()
.maxByOrNull { it.value }
?.key
?: return null
val calendar = Calendar.getInstance().apply {
set(Calendar.DAY_OF_WEEK, dominantDay)
}
val locale = AppStrings.locale(lang)
val dayName = SimpleDateFormat("EEEE", locale).format(calendar.time)
return AppStrings.format(lang, "format.new_episodes_every", dayName.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() })
}
internal fun episodeProgressFraction(progressMs: Long, runtimeLabel: String?): Float {
if (progressMs <= 0L) return 0f
val runtimeMinutes = Regex("""(\d+)""").find(runtimeLabel.orEmpty())?.groupValues?.getOrNull(1)?.toLongOrNull()
val durationMs = (runtimeMinutes ?: 45L) * 60_000L
return (progressMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
}

View file

@ -0,0 +1,16 @@
package com.fluxa.app.ui.catalog
import androidx.compose.ui.graphics.Color
object FluxaColors {
val background = Color(0xFF090B10)
val surface = Color(0xFF12161D)
val surfaceCard = Color(0xFF141922)
val surfaceRaised = Color(0xFF1B212B)
val textPrimary = Color(0xFFF4F1EA)
val accent = Color(0xFFE85D3F)
val accentGold = Color(0xFFF0C674)
val accentArgb = 0xFFE85D3F.toInt()
val progressFill = accent
val progressTrack = Color.White.copy(alpha = 0.20f)
}

View file

@ -0,0 +1,510 @@
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.animation.ExperimentalAnimationApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.core.rust.FluxaCoreNative
import com.fluxa.app.domain.discovery.*
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import coil3.compose.AsyncImage
import com.fluxa.app.player.MediaTrack
import java.util.Locale
private fun String?.isTorrentPlaybackUrlForOverlay(): Boolean {
return FluxaCoreNative.isTorrentPlaybackUrl(this)
}
private fun isEnglishUi(lang: String?): Boolean {
return lang?.substringBefore('-')?.substringBefore('_')?.equals("en", ignoreCase = true) == true
}
internal fun playerText(lang: String?, key: String): String {
return AppStrings.t(lang, "player.$key")
}
internal fun playerStatusText(lang: String?, value: String): String {
return if (value.startsWith("player.")) AppStrings.t(lang, value) else value
}
internal suspend fun resolveIntroImdbId(
viewModel: HomeViewModel,
meta: Meta,
videoId: String?,
language: String
): String? {
return viewModel.resolvePlaybackIntroImdbId(meta, videoId, language)
}
internal fun extractSeasonEpisode(videoId: String?): Pair<Int, Int>? {
return FluxaCoreNative.parseEpisodeLocator(videoId)?.let { it.season to it.episode }
}
@Composable
internal fun SkipSegmentCard(
deviceType: DeviceType,
type: String,
nextEpisode: Video? = null,
lang: String? = "en",
onSkip: () -> Unit,
onDismiss: () -> Unit
) {
if (type == "outro" && nextEpisode != null) {
NextEpisodeSkipCard(
deviceType = deviceType,
episode = nextEpisode,
lang = lang,
onSkip = onSkip
)
return
}
val label = when (type) {
"intro" -> playerText(lang, "skip_intro")
"outro" -> playerText(lang, "finish_episode")
"recap" -> playerText(lang, "skip_recap")
else -> playerText(lang, "skip")
}
val focusRequester = remember { FocusRequester() }
var isFocused by remember { mutableStateOf(false) }
LaunchedEffect(deviceType) {
if (deviceType == DeviceType.TV) focusRequester.requestFocus()
}
Box(
modifier = Modifier
.widthIn(min = if (deviceType == DeviceType.Mobile) 108.dp else 160.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.White)
.then(
if (deviceType == DeviceType.TV) {
Modifier.border(2.dp, if (isFocused) FluxaColors.accent else Color.Transparent, RoundedCornerShape(10.dp))
} else {
Modifier
}
)
.focusRequester(focusRequester)
.onFocusChanged { isFocused = it.isFocused }
.focusable()
.clickable { onSkip() }
.padding(horizontal = if (deviceType == DeviceType.Mobile) 16.dp else 28.dp, vertical = if (deviceType == DeviceType.Mobile) 8.dp else 13.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
color = Color.Black,
fontWeight = FontWeight.SemiBold,
fontSize = if (deviceType == DeviceType.Mobile) 13.sp else 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun NextEpisodeSkipCard(
deviceType: DeviceType,
episode: Video,
lang: String?,
onSkip: () -> Unit
) {
val thumbnailSize = if (deviceType == DeviceType.Mobile) 46.dp else 74.dp
val cardWidth = if (deviceType == DeviceType.Mobile) 240.dp else 364.dp
val focusRequester = remember { FocusRequester() }
var isFocused by remember { mutableStateOf(false) }
LaunchedEffect(deviceType) {
if (deviceType == DeviceType.TV) focusRequester.requestFocus()
}
Row(
modifier = Modifier
.width(cardWidth)
.clip(RoundedCornerShape(if (deviceType == DeviceType.Mobile) 12.dp else 14.dp))
.background(Color.Black.copy(alpha = 0.82f))
.border(
1.dp,
if (deviceType == DeviceType.TV && isFocused) FluxaColors.accent else Color.White.copy(alpha = 0.16f),
RoundedCornerShape(if (deviceType == DeviceType.Mobile) 12.dp else 14.dp)
)
.focusRequester(focusRequester)
.onFocusChanged { isFocused = it.isFocused }
.focusable()
.clickable { onSkip() }
.padding(if (deviceType == DeviceType.Mobile) 7.dp else 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(if (deviceType == DeviceType.Mobile) 10.dp else 12.dp)
) {
AsyncImage(
model = episode.thumbnail,
contentDescription = null,
modifier = Modifier
.size(thumbnailSize)
.clip(RoundedCornerShape(if (deviceType == DeviceType.Mobile) 8.dp else 10.dp))
.background(Color.White.copy(alpha = 0.08f)),
contentScale = ContentScale.Crop
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = AppStrings.t(lang, "auto.next_episode").uppercase(Locale.ROOT),
color = Color.White,
fontWeight = FontWeight.Black,
fontSize = if (deviceType == DeviceType.Mobile) 10.sp else 14.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = nextEpisodeSubtitle(lang, episode),
color = Color.White.copy(alpha = 0.68f),
fontWeight = FontWeight.Bold,
fontSize = if (deviceType == DeviceType.Mobile) 10.sp else 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Icon(
FluxaIcons.KeyboardArrowRight,
null,
tint = Color.White.copy(alpha = 0.92f),
modifier = Modifier.size(if (deviceType == DeviceType.Mobile) 20.dp else 28.dp)
)
}
}
private fun nextEpisodeSubtitle(lang: String?, episode: Video): String {
val season = episode.season ?: 1
val number = episode.number ?: 0
val name = episode.name.orEmpty().trim()
return if (name.isBlank()) {
AppStrings.format(lang, "player.next_episode_number_format", season, number)
} else {
AppStrings.format(lang, "player.next_episode_detail_format", season, number, name)
}
}
@Composable
internal fun SegmentSkipChevronFeedback() {
val transition = rememberInfiniteTransition(label = "segmentSkip")
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(560, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Restart
),
label = "segmentSkipPhase"
)
Canvas(modifier = Modifier.size(150.dp, 96.dp)) {
val stroke = size.minDimension * 0.09f
val centerY = size.height / 2f
val startX = size.width * 0.30f
repeat(3) { index ->
val local = ((phase + index * 0.22f) % 1f)
val alpha = 0.18f + local * 0.48f
val x = startX + index * size.width * 0.18f + local * size.width * 0.05f
drawLine(
color = Color.White.copy(alpha = alpha),
start = Offset(x - size.width * 0.055f, centerY - size.height * 0.14f),
end = Offset(x + size.width * 0.055f, centerY),
strokeWidth = stroke,
cap = StrokeCap.Round
)
drawLine(
color = Color.White.copy(alpha = alpha),
start = Offset(x + size.width * 0.055f, centerY),
end = Offset(x - size.width * 0.055f, centerY + size.height * 0.14f),
strokeWidth = stroke,
cap = StrokeCap.Round
)
}
}
}
@Composable
internal fun ArtisticLoadingOverlay(bg: String, logo: String, title: String, status: com.fluxa.app.player.TorrentStreamStatus, deviceType: DeviceType, buffer: BufferSnapshot = BufferSnapshot(), error: String? = null, currentUrl: String?, isSwitchingAudioSource: Boolean = false, currentSourceIdx: Int = 0, totalSources: Int = 0, playback: PlaybackSnapshot = PlaybackSnapshot(), lang: String? = "en") {
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
if (bg.isNotEmpty()) {
AsyncImage(
bg,
null,
modifier = Modifier
.fillMaxSize()
.alpha(if (deviceType == DeviceType.TV) 0.35f else 0.30f),
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.34f))
)
}
val currentUrlStr = currentUrl ?: ""
val isTorrent = currentUrlStr.isTorrentPlaybackUrlForOverlay() || currentUrlStr.contains(".torrent")
val bufferingMedia = buffer.loadProgress > 0f ||
buffer.bufferPercent > 0 ||
status.bufferProgress > 0 ||
status.downloadSpeed > 0.0 ||
status.activePeers > 0 ||
status.totalPeers > 0 ||
status.detailedStatus.isNotBlank()
val targetProgress = when {
playback.hasStartedPlaying && !playback.isBuffering -> 1.0f
buffer.loadProgress > 0f -> buffer.loadProgress.coerceIn(0f, 1f)
status.bufferProgress > 0 -> (status.bufferProgress / 100f).coerceIn(0f, 1f)
!isTorrent && buffer.bufferPercent > 0 -> (buffer.bufferPercent.toFloat() / 100f).coerceIn(0f, 1f)
else -> 0f
}
val loadProgress by animateFloatAsState(
targetValue = targetProgress,
animationSpec = tween(520, easing = FastOutSlowInEasing),
label = "logoLoadProgress"
)
val hasProgress = bufferingMedia || loadProgress > 0.015f || targetProgress > 0.015f
val visibleLoadProgress = if (hasProgress) maxOf(loadProgress, 0.045f) else 0f
val breatheTransition = rememberInfiniteTransition(label = "loadingLogoBreathe")
val breatheAlpha by breatheTransition.animateFloat(
initialValue = 0.42f,
targetValue = 0.66f,
animationSpec = infiniteRepeatable(
animation = tween(1120, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "loadingLogoAlpha"
)
val containerWidth = if (deviceType == DeviceType.TV) 500.dp else 280.dp
Column(modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.width(containerWidth)
.height(200.dp),
contentAlignment = Alignment.Center
) {
var logoFailed by remember { mutableStateOf(false) }
when {
logo.isEmpty() || logoFailed -> {
val spinnerAlpha = if (hasProgress) 0.18f + 0.72f * loadProgress.coerceIn(0f, 1f) else breatheAlpha
CircularProgressIndicator(
color = Color.White.copy(alpha = spinnerAlpha),
strokeWidth = 3.dp,
modifier = Modifier.size(52.dp)
)
}
!hasProgress -> {
// Idle: logo breathes at low alpha
AsyncImage(
model = logo,
contentDescription = null,
modifier = Modifier.fillMaxSize().alpha(breatheAlpha),
contentScale = ContentScale.Fit,
onError = { logoFailed = true }
)
}
else -> {
// Ghost outline underneath
AsyncImage(
model = logo,
contentDescription = null,
modifier = Modifier.fillMaxSize().alpha(0.18f),
contentScale = ContentScale.Fit,
onError = { logoFailed = true }
)
// Left-to-right reveal: clip drawing to progress fraction of width
val revealProgress = visibleLoadProgress.coerceIn(0f, 1f)
AsyncImage(
model = logo,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.drawWithContent {
clipRect(right = size.width * revealProgress) {
this@drawWithContent.drawContent()
}
},
contentScale = ContentScale.Fit,
onError = { logoFailed = true }
)
}
}
}
}
if (error != null) {
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.8f)), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(FluxaIcons.ErrorOutline, null, tint = Color.White, modifier = Modifier.size(64.dp).padding(bottom = 16.dp))
Text(text = error, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 64.dp))
}
}
}
}
}
@Composable
internal fun PlayerUIContent(
content: PlayerContentUiModel, lang: String, duration: Long, position: Long, bufferedFraction: Float, chapters: List<com.fluxa.app.player.Chapter> = emptyList(), isPlaying: Boolean, isBuffering: Boolean, hasStartedPlaying: Boolean, deviceType: DeviceType,
onPlayPause: () -> Unit, onSeek: (Long) -> Unit, onToggleSubtitles: () -> Unit, onToggleAspect: () -> Unit, onSpeedChange: (Float) -> Unit, playbackSpeed: Float, playPauseFocusRequester: FocusRequester, seekbarFocusRequester: FocusRequester,
isScrubbing: Boolean, scrubPosition: Long, onScrubbingChange: (Boolean, Long) -> Unit, onScrubSeek: (Long) -> Unit = {},
isSwitchingAudioSource: Boolean = false, detailedStatus: String = "", episodeMetaLine: String? = null, streamDetailLine: String? = null, subtitlesEnabled: Boolean = false, technicalInfo: String? = null,
supportsTrackSettings: Boolean = true,
seekForwardMs: Long = 10_000L, seekBackwardMs: Long = 10_000L,
hasPreviousEpisode: Boolean = false,
hasNextEpisode: Boolean = false,
showSourcesButton: Boolean = false,
showEpisodesButton: Boolean = false,
introDbMarkingEnabled: Boolean = false,
onPlayPrevious: () -> Unit = {},
onPlayNext: () -> Unit = {},
onCast: () -> Unit = {},
onOpenInExternalPlayer: () -> Unit = {},
onPictureInPicture: () -> Unit = {},
onShowSettings: (Int) -> Unit,
onClose: () -> Unit,
accentColor: Color = FluxaColors.accent
) {
if (deviceType == DeviceType.Mobile) {
MobilePlayerUIContent(
title = content.title,
content = content,
lang = lang,
duration = duration,
position = position,
bufferedFraction = bufferedFraction,
chapters = chapters,
isPlaying = isPlaying,
isBuffering = isBuffering,
hasStartedPlaying = hasStartedPlaying,
onPlayPause = onPlayPause,
onSeek = onSeek,
playbackSpeed = playbackSpeed,
subtitlesEnabled = subtitlesEnabled,
supportsTrackSettings = supportsTrackSettings,
technicalInfo = technicalInfo,
episodeMetaLine = episodeMetaLine,
streamDetailLine = streamDetailLine,
seekForwardMs = seekForwardMs,
seekBackwardMs = seekBackwardMs,
hasPreviousEpisode = hasPreviousEpisode,
hasNextEpisode = hasNextEpisode,
showSourcesButton = showSourcesButton,
showEpisodesButton = showEpisodesButton,
introDbMarkingEnabled = introDbMarkingEnabled,
onPlayPrevious = onPlayPrevious,
onPlayNext = onPlayNext,
onCast = onCast,
onOpenInExternalPlayer = onOpenInExternalPlayer,
onPictureInPicture = onPictureInPicture,
onToggleAspect = onToggleAspect,
onShowSettings = onShowSettings,
onClose = onClose,
isScrubbing = isScrubbing,
scrubPosition = scrubPosition,
onScrubbingChange = onScrubbingChange,
onScrubSeek = onScrubSeek,
accentColor = accentColor
)
return
}
TvPlayerUIContent(
title = content.title,
content = content,
lang = lang,
duration = duration,
position = position,
bufferedFraction = bufferedFraction,
chapters = chapters,
isPlaying = isPlaying,
isBuffering = isBuffering,
hasStartedPlaying = hasStartedPlaying,
deviceType = deviceType,
onPlayPause = onPlayPause,
onSeek = onSeek,
onToggleSubtitles = onToggleSubtitles,
onToggleAspect = onToggleAspect,
onSpeedChange = onSpeedChange,
playbackSpeed = playbackSpeed,
playPauseFocusRequester = playPauseFocusRequester,
seekbarFocusRequester = seekbarFocusRequester,
isScrubbing = isScrubbing,
scrubPosition = scrubPosition,
onScrubbingChange = onScrubbingChange,
onScrubSeek = onScrubSeek,
isSwitchingAudioSource = isSwitchingAudioSource,
detailedStatus = detailedStatus,
episodeMetaLine = episodeMetaLine,
streamDetailLine = streamDetailLine,
subtitlesEnabled = subtitlesEnabled,
supportsTrackSettings = supportsTrackSettings,
technicalInfo = technicalInfo,
seekForwardMs = seekForwardMs,
seekBackwardMs = seekBackwardMs,
hasPreviousEpisode = hasPreviousEpisode,
hasNextEpisode = hasNextEpisode,
showSourcesButton = showSourcesButton,
showEpisodesButton = showEpisodesButton,
introDbMarkingEnabled = introDbMarkingEnabled,
onPlayPrevious = onPlayPrevious,
onPlayNext = onPlayNext,
onCast = onCast,
onOpenInExternalPlayer = onOpenInExternalPlayer,
onPictureInPicture = onPictureInPicture,
onShowSettings = onShowSettings,
onClose = onClose
)
}
@Composable
fun VolumeBar(current: Int, max: Int) {
val progress = current.toFloat() / max.toFloat()
Row(modifier = Modifier.background(Color(0xB010141A), RoundedCornerShape(18.dp)).padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(imageVector = when { progress == 0f -> FluxaIcons.VolumeMute; progress < 0.5f -> FluxaIcons.VolumeDown; else -> FluxaIcons.VolumeUp }, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
Box(modifier = Modifier.width(150.dp).height(4.dp).background(Color.White.copy(alpha = 0.2f), CircleShape)) { Box(modifier = Modifier.fillMaxWidth(progress).fillMaxHeight().background(Color.White, CircleShape)) }
Text(text = "${(progress * 100).toInt()}%", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}

View file

@ -0,0 +1,505 @@
@file:androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.animation.ExperimentalAnimationApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import android.view.LayoutInflater
import android.view.SurfaceView
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fluxa.app.player.MediaPlayerController
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.fluxa.app.R
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.remote.IntroTimestamps
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.Stream
import com.fluxa.app.data.remote.Video
import com.fluxa.app.player.MediaTrack
import com.fluxa.app.player.ExternalSubtitleTrack
import com.fluxa.app.player.NativeAssTrack
import com.fluxa.app.player.MpvAndroidSurfaceView
import com.fluxa.app.player.MpvEmbeddedPlayer
import com.fluxa.app.player.PlayerEngine
import com.fluxa.app.player.TorrentStreamStatus
private data class ExoSurfaceConfig(
val resizeMode: Int,
val zoomScale: Float,
val subtitleSize: Float,
val subtitleTextOpacity: Float,
val subtitleBackgroundOpacity: Float,
val subtitleOutlineOpacity: Float,
val nativeAssOverlayActive: Boolean
)
private data class MpvSurfaceConfig(
val player: MpvEmbeddedPlayer?,
val zoomScale: Float
)
@Composable
internal fun BoxScope.PlayerPlaybackSurface(
content: PlayerContentUiModel,
currentUrl: String?,
resolvedUrl: String?,
useMpvBackend: Boolean,
mpvPlayer: MpvEmbeddedPlayer?,
exoPlayer: ExoPlayer,
activeProfile: UserProfile?,
resizeMode: Int,
playback: PlaybackSnapshot,
timeline: TimelineSnapshot,
buffer: BufferSnapshot,
render: RenderSnapshot,
playerError: String?,
torrentStatus: TorrentStreamStatus,
deviceType: DeviceType,
isSwitchingAudioSource: Boolean,
currentStreamIndex: Int,
currentStreamDetailLine: String?,
currentStreamsSize: Int,
lang: String,
showControls: Boolean,
activeEngine: PlayerEngine?,
showControlsTemp: () -> Unit,
seekSafely: (Long) -> Unit,
toggleSubtitleSelection: () -> Unit,
onToggleAspect: () -> Unit,
playbackSpeed: Float,
onPlaybackSpeedChange: (Float) -> Unit,
playPauseFocusRequester: androidx.compose.ui.focus.FocusRequester,
seekbarFocusRequester: androidx.compose.ui.focus.FocusRequester,
isScrubbing: Boolean,
scrubPosition: Long,
onScrubbingChange: (Boolean, Long) -> Unit,
currentEpisodeMetaLine: String?,
currentSubtitle: MediaTrack?,
currentExternalSubtitles: List<ExternalSubtitleTrack>,
embeddedNativeAssTracks: List<NativeAssTrack>,
subtitleDelayMs: Long,
effectiveTechnicalInfo: String?,
seekForwardMs: Long,
seekBackwardMs: Long,
hasPreviousEpisode: Boolean,
hasNextEpisode: Boolean,
nextEpisode: Video?,
onPlayPrevious: () -> Unit,
onPlayNext: () -> Unit,
onCast: () -> Unit,
onOpenInExternalPlayer: () -> Unit,
onPictureInPicture: () -> Unit,
onShowSettingsTab: (Int) -> Unit,
onClose: () -> Unit,
onNextEpisodeCardShown: () -> Unit,
timelinePosition: () -> Long,
skipSegments: List<IntroTimestamps>,
chapters: List<com.fluxa.app.player.Chapter> = emptyList(),
dismissedSkipSegments: Set<String>,
onSkipSegment: (IntroTimestamps) -> Unit,
onDismissSegment: (IntroTimestamps) -> Unit,
showSegmentSkipFeedback: Boolean,
holdSpeedVisible: Boolean,
showVolumeBar: Boolean,
currentVolume: Int,
maxVolume: Int,
showSeekFeedback: Boolean,
seekDirection: Int,
seekFeedbackMs: Long,
videoZoomScale: Float = 1.0f,
fillScale: Float = 1.0f,
showZoomOverlay: Boolean = false,
parentsGuide: List<com.fluxa.app.data.remote.ParentsGuideCategory> = emptyList(),
showParentsGuide: Boolean = false,
onParentsGuideAnimationComplete: () -> Unit = {}
) {
val seekSurfaceViewRef = remember { mutableStateOf<SurfaceView?>(null) }
if (!resolvedUrl.isNullOrEmpty()) {
if (useMpvBackend) {
val mpvSurfaceConfig = remember(mpvPlayer, videoZoomScale) {
MpvSurfaceConfig(mpvPlayer, videoZoomScale)
}
AndroidView(
factory = { ctx ->
MpvAndroidSurfaceView(ctx).apply {
applyMpvSurfaceConfig(mpvSurfaceConfig)
}
},
update = { view ->
view.applyMpvSurfaceConfig(mpvSurfaceConfig)
},
modifier = Modifier.fillMaxSize()
)
} else {
val libassRelay = remember(exoPlayer) { MediaPlayerController.getLibassRelay(exoPlayer) }
val relayRendererActive by (libassRelay?.activeRenderer?.let { it.map { r -> r != null } }
?: MutableStateFlow(false)).collectAsStateWithLifecycle(false)
val nativeAssActive = relayRendererActive && currentSubtitle != null ||
selectedNativeAssSubtitle(currentSubtitle, currentExternalSubtitles) != null ||
selectedEmbeddedNativeAssTrack(currentSubtitle, embeddedNativeAssTracks) != null
val exoSurfaceConfig = remember(
resizeMode, videoZoomScale,
activeProfile?.safeSubtitleSize,
activeProfile?.safeSubtitleTextOpacity,
activeProfile?.safeSubtitleBackgroundOpacity,
activeProfile?.safeSubtitleOutlineOpacity,
nativeAssActive
) {
ExoSurfaceConfig(
resizeMode = resizeMode,
zoomScale = videoZoomScale,
subtitleSize = activeProfile?.safeSubtitleSize ?: 20f,
subtitleTextOpacity = activeProfile?.safeSubtitleTextOpacity ?: 1f,
subtitleBackgroundOpacity = activeProfile?.safeSubtitleBackgroundOpacity ?: 0.75f,
subtitleOutlineOpacity = activeProfile?.safeSubtitleOutlineOpacity ?: 0f,
nativeAssOverlayActive = nativeAssActive
)
}
AndroidView(
factory = { ctx ->
(LayoutInflater.from(ctx).inflate(R.layout.player_view_surface, android.widget.FrameLayout(ctx), false) as PlayerView).apply {
player = exoPlayer
useController = false
setBackgroundColor(0xFF000000.toInt())
layoutParams = android.widget.FrameLayout.LayoutParams(
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.widget.FrameLayout.LayoutParams.MATCH_PARENT,
android.view.Gravity.CENTER
)
applyExoSurfaceConfig(exoSurfaceConfig, activeProfile)
}
},
modifier = Modifier.fillMaxSize(),
update = { view ->
if (seekSurfaceViewRef.value == null) {
seekSurfaceViewRef.value = view.videoSurfaceView as? SurfaceView
}
view.applyExoSurfaceConfig(exoSurfaceConfig, activeProfile)
}
)
NativeLibassSubtitleOverlay(
exoPlayer = exoPlayer,
externalSubtitle = selectedNativeAssSubtitle(currentSubtitle, currentExternalSubtitles),
embeddedSubtitle = selectedEmbeddedNativeAssTrack(currentSubtitle, embeddedNativeAssTracks),
subtitleDelayMs = subtitleDelayMs,
modifier = Modifier.fillMaxSize()
)
}
}
val currentPosition = timelinePosition()
val showLoadingOverlay = playerError != null ||
(!render.isVideoRendered && !(useMpvBackend && playback.hasStartedPlaying)) ||
(playback.hasStartedPlaying && playback.isBuffering)
if (showLoadingOverlay) {
ArtisticLoadingOverlay(content.background, content.logo, content.title, torrentStatus, deviceType, buffer, playerError, currentUrl, isSwitchingAudioSource, currentSourceIdx = currentStreamIndex + 1, totalSources = currentStreamsSize, playback, lang = lang)
Box(
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(8.dp)
) {
PlayerTopIconButton(FluxaIcons.ArrowBack, onClose)
}
}
val controlsAlpha by animateFloatAsState(if (showControls && render.isVideoRendered) 1f else 0f)
CompositionLocalProvider(
LocalSeekSurfaceView provides seekSurfaceViewRef.value,
LocalSeekExoPlayer provides exoPlayer
) {
Box(modifier = Modifier.fillMaxSize().alpha(controlsAlpha)) {
if (showControls && render.isVideoRendered) {
PlayerUIContent(
content = content,
lang = lang,
duration = timeline.duration,
position = currentPosition,
bufferedFraction = buffer.seekbarBufferedProgress,
chapters = chapters,
isPlaying = playback.isPlaying,
isBuffering = playback.isBuffering,
hasStartedPlaying = playback.hasStartedPlaying,
deviceType = deviceType,
onPlayPause = {
activeEngine?.setPaused(playback.isPlaying)
showControlsTemp()
},
onSeek = { seekSafely(it); showControlsTemp() },
onToggleSubtitles = { toggleSubtitleSelection() },
onToggleAspect = onToggleAspect,
onSpeedChange = onPlaybackSpeedChange,
playbackSpeed = playbackSpeed,
playPauseFocusRequester = playPauseFocusRequester,
seekbarFocusRequester = seekbarFocusRequester,
isScrubbing = isScrubbing,
scrubPosition = scrubPosition,
onScrubbingChange = onScrubbingChange,
onScrubSeek = { activeEngine?.seekTo(it, exact = false) },
isSwitchingAudioSource = isSwitchingAudioSource,
detailedStatus = torrentStatus.detailedStatus,
episodeMetaLine = currentEpisodeMetaLine,
streamDetailLine = currentStreamDetailLine,
subtitlesEnabled = currentSubtitle != null,
supportsTrackSettings = true,
technicalInfo = effectiveTechnicalInfo,
seekForwardMs = seekForwardMs,
seekBackwardMs = seekBackwardMs,
hasPreviousEpisode = hasPreviousEpisode,
hasNextEpisode = hasNextEpisode,
showSourcesButton = false,
showEpisodesButton = content.isSeries,
introDbMarkingEnabled = content.isSeries && activeProfile?.safeIntroDbApiKey?.isNotBlank() == true,
onPlayPrevious = onPlayPrevious,
onPlayNext = onPlayNext,
onCast = onCast,
onOpenInExternalPlayer = onOpenInExternalPlayer,
onPictureInPicture = onPictureInPicture,
onShowSettings = onShowSettingsTab,
onClose = onClose,
accentColor = Color(activeProfile?.safeAccentColorArgb ?: FluxaColors.accentArgb)
)
}
}
PlayerParentsGuideOverlay(
categories = parentsGuide,
lang = lang,
isVisible = showParentsGuide,
onAnimationComplete = onParentsGuideAnimationComplete,
accentColor = Color(activeProfile?.safeAccentColorArgb ?: FluxaColors.accentArgb),
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(start = 16.dp, top = 64.dp)
)
PlayerSkipSegmentOverlay(
currentPosition = currentPosition,
skipSegments = skipSegments,
dismissedSkipSegments = dismissedSkipSegments,
hasStartedPlaying = playback.hasStartedPlaying,
showControls = showControls,
deviceType = deviceType,
nextEpisode = nextEpisode,
nextEpisodeThresholdReached = timeline.duration > 0L &&
currentPosition >= (timeline.duration * ((activeProfile?.safeNextEpisodeThresholdPercent ?: 90f) / 100f)).toLong(),
autoSkipSegments = activeProfile?.safeAutoSkipIntro == true,
lang = lang,
onSkipSegment = onSkipSegment,
onPlayNextEpisode = onPlayNext,
onDismissSegment = onDismissSegment,
onNextEpisodeCardShown = {
onNextEpisodeCardShown()
},
modifier = Modifier.align(Alignment.BottomEnd)
)
val zoomOverlayMode = when {
resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT -> ZoomOverlayMode.Original
videoZoomScale <= fillScale * 1.05f -> ZoomOverlayMode.Fit
else -> ZoomOverlayMode.Zoom
}
val zoomLabelText = when (zoomOverlayMode) {
ZoomOverlayMode.Original -> AppStrings.t(lang, "player.zoom_original")
ZoomOverlayMode.Fit -> AppStrings.t(lang, "player.zoom_fill")
ZoomOverlayMode.Zoom -> "${"%.1f".format(videoZoomScale / fillScale)}x"
}
PlayerTransientOverlays(
showSegmentSkipFeedback = showSegmentSkipFeedback,
holdSpeedVisible = holdSpeedVisible,
activeProfile = activeProfile,
deviceType = deviceType,
showVolumeBar = showVolumeBar,
currentVolume = currentVolume,
maxVolume = maxVolume,
showSeekFeedback = showSeekFeedback,
seekDirection = seekDirection,
seekFeedbackMs = seekFeedbackMs,
seekForwardMs = seekForwardMs,
seekBackwardMs = seekBackwardMs,
showZoomOverlay = showZoomOverlay,
zoomOverlayMode = zoomOverlayMode,
zoomLabelText = zoomLabelText
)
} // CompositionLocalProvider
}
private fun PlayerView.applyExoSurfaceConfig(config: ExoSurfaceConfig, profile: UserProfile?) {
if (getTag(R.id.player_surface_config_tag) == config) return
// AspectRatioFrameLayout.RESIZE_MODE_ZOOM doesn't reliably crop on every device, so
// crop-to-fill is driven entirely by the manual scale (fillScale) computed from the
// actual video/container aspect ratio instead, matching the mpv backend's approach.
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
scaleX = config.zoomScale
scaleY = config.zoomScale
subtitleView?.let { sv ->
sv.setApplyEmbeddedStyles(true)
sv.setApplyEmbeddedFontSizes(true)
sv.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, config.subtitleSize)
sv.setStyle(subtitleCaptionStyle(profile))
sv.visibility = if (config.nativeAssOverlayActive) android.view.View.GONE else android.view.View.VISIBLE
}
setTag(R.id.player_surface_config_tag, config)
}
private fun MpvAndroidSurfaceView.applyMpvSurfaceConfig(config: MpvSurfaceConfig) {
if (getTag(R.id.player_surface_config_tag) == config) return
bind(config.player)
scaleX = config.zoomScale
scaleY = config.zoomScale
setTag(R.id.player_surface_config_tag, config)
}
@Composable
internal fun PlayerSettingsPanel(
meta: Meta,
currentVideoId: String?,
deviceType: DeviceType,
viewModel: HomeViewModel,
activeProfile: UserProfile?,
lang: String,
activeSettingsTab: Int,
currentStreams: List<Stream>,
currentUrl: String?,
currentStreamIndex: Int,
availableAudios: List<MediaTrack>,
currentAudio: MediaTrack?,
availableSubtitles: List<MediaTrack>,
currentSubtitle: MediaTrack?,
playbackSpeed: Float,
audioDelayMs: Long,
subtitleDelayMs: Long,
onEpisodeSelected: (String, String?) -> Unit,
onCloseSettings: () -> Unit,
onSelectStreamIndex: (Int) -> Unit,
onSelectAudio: (MediaTrack) -> Unit,
onSelectSubtitle: (MediaTrack) -> Unit,
onDisableSubtitle: () -> Unit,
onSpeedChange: (Float) -> Unit,
onAudioDelayChange: (Long) -> Unit,
onSubtitleDelayChange: (Long) -> Unit,
onSubtitleTextOpacityChange: (Float) -> Unit,
onSubtitleBackgroundOpacityChange: (Float) -> Unit,
onSubtitleOutlineOpacityChange: (Float) -> Unit,
currentPositionMs: Long = 0L,
markSegmentType: String? = null,
markSegmentStartMs: Long? = null,
markSegmentEndMs: Long? = null,
markSegmentSubmitting: Boolean = false,
markSegmentFeedback: String? = null,
markSegmentCooldownRemainingSec: Long? = null,
onSelectMarkSegmentType: (String) -> Unit = {},
onMarkSegmentStart: () -> Unit = {},
onMarkSegmentEnd: () -> Unit = {},
onAdjustMarkSegmentStart: (Long) -> Unit = {},
onAdjustMarkSegmentEnd: (Long) -> Unit = {},
onSubmitMarkSegment: () -> Unit = {}
) {
if (activeSettingsTab == 5) {
MarkSegmentSidebar(
deviceType = deviceType,
lang = lang,
selectedType = markSegmentType,
startMs = markSegmentStartMs,
endMs = markSegmentEndMs,
currentPositionMs = currentPositionMs,
submitting = markSegmentSubmitting,
cooldownRemainingSec = markSegmentCooldownRemainingSec,
feedback = markSegmentFeedback,
onSelectType = onSelectMarkSegmentType,
onMarkStart = onMarkSegmentStart,
onMarkEnd = onMarkSegmentEnd,
onAdjustStart = onAdjustMarkSegmentStart,
onAdjustEnd = onAdjustMarkSegmentEnd,
onSubmit = onSubmitMarkSegment,
onClose = onCloseSettings
)
} else if (activeSettingsTab == 3 && meta.type == "series") {
EpisodeSidebar(
meta = meta,
currentId = currentVideoId ?: meta.id,
deviceType = deviceType,
viewModel = viewModel,
activeProfile = activeProfile,
onSelect = onEpisodeSelected,
onClose = onCloseSettings
)
} else if (activeSettingsTab == 4) {
SourceSidebar(
streams = currentStreams,
currentUrl = currentUrl.orEmpty(),
deviceType = deviceType,
lang = lang,
onSelect = { selectedUrl ->
val selectedIndex = currentStreams.indexOfFirst { it.playableUrl == selectedUrl }
if (selectedIndex >= 0) onSelectStreamIndex(selectedIndex)
onCloseSettings()
},
onClose = onCloseSettings
)
} else {
UniversalSettingsSidebar(
activeTab = activeSettingsTab,
audioTracks = availableAudios,
currentAudio = currentAudio,
subtitleTracks = availableSubtitles,
currentSubtitle = currentSubtitle,
playbackSpeed = playbackSpeed,
audioDelayMs = audioDelayMs,
subtitleDelayMs = subtitleDelayMs,
subtitleTextOpacity = activeProfile?.safeSubtitleTextOpacity ?: 1f,
subtitleBackgroundOpacity = activeProfile?.safeSubtitleBackgroundOpacity ?: 0.5f,
subtitleOutlineOpacity = activeProfile?.safeSubtitleOutlineOpacity ?: 1f,
onSelectAudio = {
onSelectAudio(it)
onCloseSettings()
},
onSelectSubtitle = {
onSelectSubtitle(it)
onCloseSettings()
},
onDisableSubtitle = {
onDisableSubtitle()
onCloseSettings()
},
onSpeedChange = {
onSpeedChange(it)
onCloseSettings()
},
onAudioDelayChange = onAudioDelayChange,
onSubtitleDelayChange = onSubtitleDelayChange,
onSubtitleTextOpacityChange = onSubtitleTextOpacityChange,
onSubtitleBackgroundOpacityChange = onSubtitleBackgroundOpacityChange,
onSubtitleOutlineOpacityChange = onSubtitleOutlineOpacityChange,
deviceType = deviceType,
lang = lang,
onClose = onCloseSettings
)
}
}

View file

@ -0,0 +1,120 @@
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.animation.ExperimentalAnimationApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.domain.discovery.*
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import coil3.compose.AsyncImage
import com.fluxa.app.player.MediaTrack
import java.util.Locale
@Composable
fun TrackSidebar(title: String, tracks: List<MediaTrack>, selected: MediaTrack?, deviceType: DeviceType, lang: String = "en", onSelect: (MediaTrack) -> Unit) {
PlayerSidebarShell(
title = title,
subtitle = AppStrings.t(lang, "player.choose_preferred_source"),
deviceType = deviceType
) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
items(tracks, key = { it.id }) { track ->
TrackItem(
title = track.label,
isSelected = track == selected,
onClick = { onSelect(track) },
subtitle = track.language?.let { nativeLanguageName(it) },
deviceType = deviceType
)
}
}
}
}
@Composable
fun QuickSettingsSidebar(profile: UserProfile?, onUpdateProfile: (UserProfile) -> Unit, currentOffset: Long, onOffsetChange: (Long) -> Unit, deviceType: DeviceType, lang: String = profile?.safeLanguage ?: "en", onClose: () -> Unit) {
PlayerSidebarShell(
title = AppStrings.t(lang, "player.quick_settings_title"),
subtitle = AppStrings.t(lang, "player.quick_settings_subtitle"),
deviceType = deviceType,
onClose = onClose
) {
Text(AppStrings.t(lang, "player.subtitle_sync_title"), color = Color.White.copy(alpha = 0.62f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 12.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(22.dp))
.background(Color.White.copy(alpha = 0.04f))
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(22.dp))
.padding(18.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SeekIconButton(FluxaIcons.Remove, deviceType) { onOffsetChange(currentOffset - 500) }
Text(text = "${if(currentOffset >= 0) "+" else ""}${currentOffset/1000.0}s", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Black, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
SeekIconButton(FluxaIcons.Add, deviceType) { onOffsetChange(currentOffset + 500) }
}
}
Spacer(Modifier.height(8.dp))
}
}
@Composable
fun PlayerPremiumToggle(title: String, desc: String, isEnabled: Boolean, onToggle: () -> Unit) {
Box(modifier = Modifier.fillMaxWidth().height(82.dp).clip(RoundedCornerShape(22.dp)).background(Color.White.copy(alpha = 0.05f)).border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(22.dp)).clickable { onToggle() }.padding(horizontal = 20.dp), contentAlignment = Alignment.CenterStart) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(title, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Bold)
Text(desc, color = Color.White.copy(alpha = 0.56f), fontSize = 12.sp)
}
Switch(checked = isEnabled, onCheckedChange = { onToggle() }, colors = SwitchDefaults.colors(checkedThumbColor = FluxaColors.accent, checkedTrackColor = FluxaColors.accent))
}
}
}

View file

@ -17,9 +17,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
data class ContentColors(
val dominant: Color = Color(0xFFE85D3F),
val darkMuted: Color = Color(0xFF12161D),
val lightVibrant: Color = Color(0xFFF4F1EA)
val dominant: Color = FluxaColors.accent,
val darkMuted: Color = FluxaColors.surface,
val lightVibrant: Color = FluxaColors.textPrimary
)
object ThemeHelper {
@ -42,9 +42,9 @@ object ThemeHelper {
val palette = Palette.from(bitmap).generate()
ContentColors(
dominant = palette.vibrantSwatch?.let { Color(it.rgb) } ?: Color(0xFFE85D3F),
darkMuted = palette.darkMutedSwatch?.let { Color(it.rgb) } ?: Color(0xFF12161D),
lightVibrant = palette.lightVibrantSwatch?.let { Color(it.rgb) } ?: Color(0xFFF4F1EA)
dominant = palette.vibrantSwatch?.let { Color(it.rgb) } ?: FluxaColors.accent,
darkMuted = palette.darkMutedSwatch?.let { Color(it.rgb) } ?: FluxaColors.surface,
lightVibrant = palette.lightVibrantSwatch?.let { Color(it.rgb) } ?: FluxaColors.textPrimary
).also { colorCache.put(url, it) }
} catch (e: Exception) {
ContentColors()

View file

@ -0,0 +1,466 @@
@file:androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.layout.ExperimentalLayoutApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.domain.discovery.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.*
import coil3.compose.AsyncImage
import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.request.crossfade
import coil3.request.transformations
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
internal fun TvMovieCard(
meta: Meta,
onFocus: (Meta) -> Unit,
onClick: () -> Unit,
cardLayout: String,
onForgetProgress: (() -> Unit)? = null,
onProgressActions: (() -> Unit)? = null,
artworkPreference: String? = null,
profile: UserProfile? = null,
cardScale: Float = 1f,
showHorizontalLogo: Boolean = true,
topTenRank: Int? = null,
isShelfStyle: Boolean = false,
focusRequester: FocusRequester? = null,
upFocusRequester: FocusRequester? = null,
onExpandedChange: ((Boolean) -> Unit)? = null,
onExpandedPositioned: ((LayoutCoordinates) -> Unit)? = null,
onFocusedPositioned: ((LayoutCoordinates) -> Unit)? = null,
onFocusChanged: ((Boolean) -> Unit)? = null,
onResolveTrailer: (suspend (Meta) -> String?)? = null
) {
val context = LocalContext.current
var isFocused by remember { mutableStateOf(false) }
var lastCardCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
var pressStartTime by remember { mutableStateOf(0L) }
val effectiveCardLayout = if (meta.type == "catalog_folder") {
when (meta.reason) {
"wide" -> "horizontal"
"square" -> "square"
"poster" -> "vertical"
else -> cardLayout
}
} else {
cardLayout
}
val isHorizontal = effectiveCardLayout == "horizontal"
val isEpisodeStyle = effectiveCardLayout == "episode"
val radius = posterCornerRadius(profile?.safeCardCornerPreset ?: "soft")
val animationDuration = when {
profile?.safeAnimationsEnabled == false -> 0
else -> if (isShelfStyle) 220 else 240
}
val hidePosterTitles = profile?.safePosterHideTitles == true || meta.hideTitle == true
val width = (when {
isEpisodeStyle -> if (isShelfStyle) 336.dp else 356.dp
isHorizontal -> horizontalCardWidth(profile?.safePosterWidthPreset ?: "medium", DeviceType.TV)
isShelfStyle -> posterCardWidth(profile?.safePosterWidthPreset ?: "medium") + 42.dp
else -> 136.dp
}) * cardScale
val baseImageHeight = (when {
isEpisodeStyle -> if (isShelfStyle) 210.dp else 208.dp
isHorizontal -> horizontalCardHeight(profile?.safePosterWidthPreset ?: "medium", DeviceType.TV)
isShelfStyle -> posterCardHeight(profile?.safePosterWidthPreset ?: "medium") + 64.dp
else -> 204.dp
}) * cardScale
val expandedPostersEnabled = isShelfStyle && !isHorizontal && !isEpisodeStyle && profile?.safeExpandedPostersEnabled == true
var expanded by remember { mutableStateOf(false) }
LaunchedEffect(isFocused, expandedPostersEnabled) {
if (isFocused && expandedPostersEnabled) {
val expandedArtwork = preferredHorizontalArtwork(meta) ?: meta.poster
if (!expandedArtwork.isNullOrBlank()) {
launch {
context.imageLoader.execute(
ImageRequest.Builder(context)
.data(expandedArtwork)
.memoryCacheKey(expandedArtwork)
.diskCacheKey(expandedArtwork)
.size(640, 360)
.build()
)
}
}
if (!meta.logo.isNullOrBlank()) {
launch {
context.imageLoader.execute(
ImageRequest.Builder(context)
.data(meta.logo)
.memoryCacheKey("home-logo:${meta.logo}")
.diskCacheKey(meta.logo)
.build()
)
}
}
val delaySeconds = profile?.safeExpandedPostersDelaySeconds ?: 2
if (delaySeconds > 0) {
delay(delaySeconds * 1000L)
}
expanded = true
} else {
expanded = false
}
}
LaunchedEffect(expanded) { onExpandedChange?.invoke(expanded) }
var expandedSettled by remember { mutableStateOf(false) }
LaunchedEffect(expanded, animationDuration) {
if (expanded) {
delay(animationDuration.toLong() + 30L)
expandedSettled = true
} else {
expandedSettled = false
}
}
val trailerOnExpandedPostersEnabled = expandedPostersEnabled && profile?.safeTrailerOnExpandedPostersEnabled == true && onResolveTrailer != null
var trailerUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(expanded, trailerOnExpandedPostersEnabled) {
if (expanded && trailerOnExpandedPostersEnabled) {
val delaySeconds = profile?.safeTrailerOnExpandedPostersDelaySeconds ?: 3
if (delaySeconds > 0) delay(delaySeconds * 1000L)
trailerUrl = onResolveTrailer?.invoke(meta)
} else {
trailerUrl = null
}
}
var trailerPlayer by remember { mutableStateOf<androidx.media3.exoplayer.ExoPlayer?>(null) }
DisposableEffect(Unit) {
onDispose { trailerPlayer?.release() }
}
LaunchedEffect(trailerUrl) {
val url = trailerUrl
if (url == null) {
trailerPlayer?.stop()
trailerPlayer?.clearMediaItems()
} else {
val playerInstance = trailerPlayer ?: androidx.media3.exoplayer.ExoPlayer.Builder(context).build().also { trailerPlayer = it }
playerInstance.volume = 0f
playerInstance.repeatMode = androidx.media3.common.Player.REPEAT_MODE_OFF
playerInstance.setMediaItem(androidx.media3.common.MediaItem.fromUri(url))
playerInstance.prepare()
playerInstance.playWhenReady = true
}
}
val imageHeight = baseImageHeight
val effectiveWidth = if (expanded) imageHeight * 16f / 9f else width
val isWideTopTenCard = isHorizontal || isEpisodeStyle
val rankNumberBoxWidth = when {
topTenRank == null -> 0.dp
topTenRank >= 10 -> if (isWideTopTenCard) width * 0.98f else width * 1.18f
topTenRank == 1 -> if (isWideTopTenCard) width * 0.46f else width * 0.54f
else -> if (isWideTopTenCard) width * 0.72f else width * 0.86f
}
val rankPosterOverlap = when {
topTenRank == null -> 0.dp
topTenRank >= 10 -> if (isWideTopTenCard) width * 0.30f else width * 0.34f
topTenRank == 1 -> if (isWideTopTenCard) width * 0.12f else width * 0.14f
else -> if (isWideTopTenCard) width * 0.22f else width * 0.25f
}
val outerWidth = if (topTenRank != null) rankNumberBoxWidth + effectiveWidth - rankPosterOverlap else effectiveWidth
val topTenFontSize = when {
!isWideTopTenCard -> 226.sp
isEpisodeStyle -> 158.sp
else -> 150.sp
}
val topTenNumberYOffset = if (isWideTopTenCard) 1.dp else 2.dp
Box(
modifier = Modifier
.width(outerWidth)
.height(imageHeight)
.zIndex(if (isFocused) 100f else 1f)
.then(
if (focusRequester != null || upFocusRequester != null) {
Modifier
.let { base -> if (focusRequester != null) base.focusRequester(focusRequester) else base }
.focusProperties { if (upFocusRequester != null) up = upFocusRequester }
} else {
Modifier
}
)
) {
topTenRank?.let { rank ->
Box(
modifier = Modifier
.align(Alignment.TopStart)
.width(rankNumberBoxWidth)
.height(imageHeight)
.zIndex(0f),
contentAlignment = Alignment.BottomEnd
) {
TopTenRankNumber(
rank = rank,
fontSize = topTenFontSize,
modifier = Modifier.offset(
x = when {
rank == 1 -> 8.dp
rank >= 10 -> 0.dp
else -> 3.dp
},
y = topTenNumberYOffset
)
)
}
}
Surface(
onClick = onClick,
modifier = Modifier
.align(Alignment.TopEnd)
.width(effectiveWidth)
.height(imageHeight)
.onGloballyPositioned { coords ->
lastCardCoordinates = coords
if (expanded) onExpandedPositioned?.invoke(coords)
if (isFocused) onFocusedPositioned?.invoke(coords)
}
.onFocusChanged {
isFocused = it.isFocused
onFocusChanged?.invoke(it.isFocused)
if (it.isFocused) {
onFocus(meta)
lastCardCoordinates?.let { coords -> onFocusedPositioned?.invoke(coords) }
}
}
.onPreviewKeyEvent { event ->
val action = onProgressActions ?: onForgetProgress
if (action == null || (event.key != Key.DirectionCenter && event.key != Key.Enter)) return@onPreviewKeyEvent false
when (event.type) {
KeyEventType.KeyDown -> {
if (pressStartTime == 0L) pressStartTime = System.currentTimeMillis()
false
}
KeyEventType.KeyUp -> {
val heldMs = System.currentTimeMillis() - pressStartTime
pressStartTime = 0L
if (heldMs >= 500L) { action.invoke(); true } else false
}
else -> false
}
},
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(radius)),
colors = ClickableSurfaceDefaults.colors(containerColor = FluxaColors.surface, focusedContainerColor = FluxaColors.surfaceRaised),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f)
) {
Column(modifier = Modifier.fillMaxSize()) {
val lang = profile?.safeLanguage ?: "en"
val isUpcomingRelease = isUpcomingRelease(meta.released)
val isProgressCard = (meta.timeOffset ?: 0L) > 0L && (meta.duration ?: 0L) > 0L
val artwork = remember(meta.id, meta.poster, meta.background, meta.continueWatchingBackground, meta.focusGifUrl, effectiveCardLayout, artworkPreference, isFocused, expanded) {
when {
isEpisodeStyle -> {
val seriesArtwork = when (artworkPreference) {
"episode" -> meta.continueWatchingBackground
"poster" -> meta.poster
"background" -> meta.background
else -> meta.continueWatchingBackground
}
val resolved = if (meta.type == "series" || meta.type == "tv" || meta.type == "anime") seriesArtwork ?: meta.background else meta.background
resolved ?: meta.poster
}
isFocused && !meta.focusGifUrl.isNullOrBlank() && meta.type != "catalog_folder" -> meta.focusGifUrl
expanded -> preferredHorizontalArtwork(meta) ?: meta.poster
isHorizontal -> preferredHorizontalArtwork(meta) ?: meta.poster
else -> meta.poster
}
}
val requestWidth = if (isEpisodeStyle) 512 else if (isHorizontal || expanded) 640 else 320
val requestHeight = if (isEpisodeStyle) 288 else if (isHorizontal || expanded) 360 else 480
val request = remember(context, artwork, requestWidth, requestHeight) {
ImageRequest.Builder(context)
.data(artwork)
.crossfade(true)
.memoryCacheKey(artwork)
.diskCacheKey(artwork)
.placeholderMemoryCacheKey(meta.poster)
.size(requestWidth, requestHeight)
.build()
}
var failed by remember(meta.id, artwork) { mutableStateOf(artwork.isNullOrBlank()) }
val showLogo = (isHorizontal || expandedSettled) && !isEpisodeStyle && showHorizontalLogo && meta.type != "catalog_folder" && (meta.timeOffset ?: 0L) <= 0L && !meta.logo.isNullOrBlank()
val logoRequest = remember(meta.logo, showLogo) {
if (!showLogo) null else ImageRequest.Builder(context)
.data(meta.logo)
.crossfade(true)
.memoryCacheKey("home-logo:${meta.logo}")
.diskCacheKey(meta.logo)
.transformations(TrimTransparentEdgesTransformation())
.build()
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(imageHeight)
.clip(RoundedCornerShape(radius))
.background(if (isEpisodeStyle) FluxaColors.surfaceCard else Color.White.copy(alpha = FluxaDimensions.Alpha.emptyCardBackground))
.then(if (isFocused) Modifier.border(3.5.dp, Color(profile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt()), RoundedCornerShape(radius)) else Modifier)
) {
if (trailerUrl != null && trailerPlayer != null) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { ctx ->
androidx.media3.ui.PlayerView(ctx).apply {
useController = false
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
player = trailerPlayer
}
},
update = { it.player = trailerPlayer },
modifier = Modifier.fillMaxSize()
)
} else if (!failed) {
AsyncImage(
model = request,
contentDescription = null,
modifier = Modifier.fillMaxSize().alpha(if (isUpcomingRelease && !isFocused) 0.6f else 1.0f),
contentScale = ContentScale.Crop,
onError = { failed = true }
)
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = meta.coverEmoji?.takeIf { it.isNotBlank() } ?: meta.name.take(1).uppercase(),
color = Color.White.copy(alpha = if (meta.coverEmoji.isNullOrBlank()) 0.2f else 0.82f),
fontSize = if (meta.coverEmoji.isNullOrBlank()) 48.sp else 42.sp,
fontWeight = FontWeight.Black
)
}
}
if (isEpisodeStyle) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(112.dp)
.background(
Brush.verticalGradient(
0f to Color.Transparent,
1f to Color.Black.copy(alpha = if (isFocused) 0.88f else 0.76f)
)
)
)
if (!meta.isUpNextContinueItem() && isProgressCard) {
val progress = ((meta.timeOffset ?: 0L).toFloat() / (meta.duration ?: 1L).toFloat()).coerceIn(0f, 1f)
val remainingMs = ((meta.duration ?: 0L) - (meta.timeOffset ?: 0L)).coerceAtLeast(0L)
Column(modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(12.dp)) {
Text(
text = formatRemainingTime(remainingMs, lang),
color = Color.White.copy(alpha = 0.82f),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 6.dp)
)
Box(modifier = Modifier.fillMaxWidth().height(4.dp).background(Color.White.copy(alpha = 0.20f))) {
Box(modifier = Modifier.fillMaxWidth(progress).fillMaxHeight().background(Color.White))
}
}
}
} else {
if (showLogo || isProgressCard) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f)), startY = 150f))
)
}
if (showLogo && logoRequest != null) {
AsyncImage(
model = logoRequest,
contentDescription = null,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = if (expanded) 16.dp else 9.dp, bottom = if (expanded) 16.dp else 9.dp)
.widthIn(max = effectiveWidth * 0.42f)
.heightIn(max = imageHeight * 0.30f),
contentScale = ContentScale.Fit
)
}
if (isProgressCard) {
val progress = ((meta.timeOffset ?: 0L).toFloat() / (meta.duration ?: 1L).toFloat()).coerceIn(0f, 1f)
val remainingMs = ((meta.duration ?: 0L) - (meta.timeOffset ?: 0L)).coerceAtLeast(0L)
Column(modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().padding(8.dp)) {
Text(
text = formatRemainingTime(remainingMs, lang),
color = Color.White.copy(alpha = 0.7f),
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 3.dp)
)
Box(modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(1.5.dp)).background(Color.White.copy(alpha = 0.2f))) {
Box(modifier = Modifier.fillMaxWidth(progress).fillMaxHeight().background(Color.White))
}
}
}
}
}
if (!isEpisodeStyle && !hidePosterTitles && !expanded) {
Text(
text = meta.name,
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp)
)
val secondaryText = meta.releaseInfo?.take(4) ?: meta.released?.take(4) ?: ""
if (secondaryText.isNotBlank()) {
Text(
text = secondaryText,
color = Color.White.copy(alpha = 0.62f),
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 1.dp)
)
}
}
}
}
if (isFocused && onFocusedPositioned == null) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.width(effectiveWidth)
.height(imageHeight)
.zIndex(101f)
.padding(4.dp)
.border(3.5.dp, Color(profile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt()), RoundedCornerShape(radius))
)
}
}
}

View file

@ -0,0 +1,419 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.domain.discovery.*
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.*
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun TVSeekbar(
position: Long,
duration: Long,
bufferedFraction: Float,
onSeek: (Long) -> Unit,
focusRequester: FocusRequester,
playPauseFocusRequester: FocusRequester,
onScrubbing: (Boolean, Long) -> Unit,
seekForwardMs: Long = 10_000L,
seekBackwardMs: Long = 10_000L,
chapters: List<com.fluxa.app.player.Chapter> = emptyList(),
isPlaying: Boolean = false,
onScrubSeek: (Long) -> Unit = {}
) {
var isFocused by remember { mutableStateOf(false) }
var internalPos by remember { mutableFloatStateOf(position.toFloat()) }
val seekbarAccent = FluxaColors.accent
val seekPreview = rememberSeekThumbnail(LocalSeekSurfaceView.current, internalPos.toLong(), isFocused, position, isPlaying, onScrubSeek)
LaunchedEffect(position) { if(!isFocused) internalPos = position.toFloat() }
val chapterBoundaries = remember(chapters, duration) {
if (chapters.size >= 2 && duration > 0L) {
(chapters.map { it.startMs.toFloat() / duration } + 1f).sorted()
} else {
emptyList()
}
}
val focusedChapterTitle = remember(chapters, internalPos, isFocused) {
if (!isFocused || chapters.isEmpty()) null
else chapters.lastOrNull { it.startMs <= internalPos }?.title?.takeIf { it.isNotBlank() }
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.focusRequester(focusRequester)
.onFocusChanged {
isFocused = it.isFocused
onScrubbing(it.isFocused, internalPos.toLong())
}
.focusProperties { up = playPauseFocusRequester }
.focusable()
.onKeyEvent {
if (it.type == KeyEventType.KeyDown) {
when (it.key) {
Key.DirectionLeft -> {
internalPos = (internalPos - seekBackwardMs).coerceAtLeast(0f)
onScrubbing(true, internalPos.toLong())
true
}
Key.DirectionRight -> {
internalPos = (internalPos + seekForwardMs).coerceAtMost(duration.toFloat())
onScrubbing(true, internalPos.toLong())
true
}
Key.Enter, Key.DirectionCenter -> {
onSeek(internalPos.toLong())
onScrubbing(false, internalPos.toLong())
true
}
else -> false
}
} else false
}
.pointerInput(Unit) {
detectTapGestures { offset ->
val newPos = (offset.x / size.width) * duration
internalPos = newPos.coerceIn(0f, duration.toFloat())
onSeek(internalPos.toLong())
}
}
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { onScrubbing(true, internalPos.toLong()) },
onDragEnd = { onSeek(internalPos.toLong()); onScrubbing(false, internalPos.toLong()) },
onDrag = { change, _ ->
val newPos = (change.position.x / size.width) * duration
internalPos = newPos.coerceIn(0f, duration.toFloat())
onScrubbing(true, internalPos.toLong())
}
)
}
) {
if (isFocused && seekPreview != null) {
val progress = if (duration > 0) internalPos / duration else 0f
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val cardWidth = 240.dp
val rawLeft = maxWidth * progress.coerceIn(0f, 1f) - cardWidth / 2
val clampedLeft = rawLeft.coerceIn(0.dp, maxOf(0.dp, maxWidth - cardWidth))
androidx.compose.foundation.Image(
bitmap = seekPreview,
contentDescription = null,
modifier = Modifier
.offset(x = clampedLeft, y = (-160).dp)
.width(cardWidth)
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(8.dp)),
contentScale = androidx.compose.ui.layout.ContentScale.Crop
)
}
}
if (focusedChapterTitle != null) {
val progress = if (duration > 0) internalPos / duration else 0f
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val labelWidth = 220.dp
val rawLeft = maxWidth * progress.coerceIn(0f, 1f) - labelWidth / 2
val clampedLeft = rawLeft.coerceIn(0.dp, maxOf(0.dp, maxWidth - labelWidth))
Text(
text = focusedChapterTitle,
color = Color.White.copy(alpha = 0.85f),
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.offset(x = clampedLeft, y = (-4).dp)
.width(labelWidth)
)
}
}
Canvas(modifier = Modifier.fillMaxSize().padding(vertical = 22.dp)) {
val trackHeight = 3.dp.toPx()
val thumbRadius = if (isFocused) 6.dp.toPx() else 4.dp.toPx()
val progress = if (duration > 0) internalPos / duration else 0f
val visibleBufferedFraction = bufferedFraction.coerceIn(0f, 1f).coerceAtLeast(progress)
val cornerRadius = androidx.compose.ui.geometry.CornerRadius(trackHeight / 2)
if (chapterBoundaries.isEmpty()) {
drawRoundRect(
color = Color.Black,
size = androidx.compose.ui.geometry.Size(size.width, trackHeight),
cornerRadius = cornerRadius
)
drawRoundRect(
color = Color(0xFFBDBDBD),
size = androidx.compose.ui.geometry.Size(size.width * visibleBufferedFraction, trackHeight),
cornerRadius = cornerRadius
)
drawRoundRect(
color = seekbarAccent,
size = androidx.compose.ui.geometry.Size(size.width * progress, trackHeight),
cornerRadius = cornerRadius
)
} else {
val gapPx = 3.dp.toPx()
var start = 0f
for (end in chapterBoundaries) {
val left = size.width * start + gapPx / 2f
val right = (size.width * end - gapPx / 2f).coerceAtLeast(left)
val segWidth = right - left
if (segWidth > 0f) {
drawRoundRect(
color = Color.Black,
topLeft = androidx.compose.ui.geometry.Offset(left, 0f),
size = androidx.compose.ui.geometry.Size(segWidth, trackHeight),
cornerRadius = cornerRadius
)
val bufferedRight = (size.width * visibleBufferedFraction).coerceIn(left, right)
if (bufferedRight > left) {
drawRoundRect(
color = Color(0xFFBDBDBD),
topLeft = androidx.compose.ui.geometry.Offset(left, 0f),
size = androidx.compose.ui.geometry.Size(bufferedRight - left, trackHeight),
cornerRadius = cornerRadius
)
}
val activeRight = (size.width * progress).coerceIn(left, right)
if (activeRight > left) {
drawRoundRect(
color = seekbarAccent,
topLeft = androidx.compose.ui.geometry.Offset(left, 0f),
size = androidx.compose.ui.geometry.Size(activeRight - left, trackHeight),
cornerRadius = cornerRadius
)
}
}
start = end
}
}
drawCircle(
color = seekbarAccent,
radius = thumbRadius,
center = androidx.compose.ui.geometry.Offset(size.width * progress, trackHeight / 2)
)
}
}
}
@Composable
fun PlayerControlBtn(icon: ImageVector, deviceType: DeviceType, onClick: () -> Unit) {
Box(modifier = Modifier.size(42.dp).clip(RoundedCornerShape(12.dp)).background(Color.White.copy(alpha = 0.04f)).clickable { onClick() }.then(if(deviceType == DeviceType.TV) Modifier.focusable() else Modifier), contentAlignment = Alignment.Center) {
Icon(icon, null, modifier = Modifier.size(20.dp), tint = Color.White)
}
}
@Composable
fun SeekIconButton(icon: ImageVector, deviceType: DeviceType, onClick: () -> Unit) {
Box(modifier = Modifier.size(52.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.06f)).clickable { onClick() }.then(if(deviceType == DeviceType.TV) Modifier.focusable() else Modifier), contentAlignment = Alignment.Center) {
Icon(icon, null, modifier = Modifier.size(24.dp), tint = Color.White)
}
}
@Composable
internal fun TvPlayerUIContent(
title: String, content: PlayerContentUiModel, lang: String, duration: Long, position: Long, bufferedFraction: Float, chapters: List<com.fluxa.app.player.Chapter> = emptyList(), isPlaying: Boolean, isBuffering: Boolean, hasStartedPlaying: Boolean, deviceType: DeviceType,
onPlayPause: () -> Unit, onSeek: (Long) -> Unit, onToggleSubtitles: () -> Unit, onToggleAspect: () -> Unit, onSpeedChange: (Float) -> Unit, playbackSpeed: Float, playPauseFocusRequester: FocusRequester, seekbarFocusRequester: FocusRequester,
isScrubbing: Boolean, scrubPosition: Long, onScrubbingChange: (Boolean, Long) -> Unit, onScrubSeek: (Long) -> Unit = {},
isSwitchingAudioSource: Boolean = false, detailedStatus: String = "", episodeMetaLine: String? = null, streamDetailLine: String? = null, subtitlesEnabled: Boolean = false, technicalInfo: String? = null,
supportsTrackSettings: Boolean = true,
seekForwardMs: Long = 10_000L, seekBackwardMs: Long = 10_000L,
hasPreviousEpisode: Boolean = false,
hasNextEpisode: Boolean = false,
showSourcesButton: Boolean = false,
showEpisodesButton: Boolean = false,
introDbMarkingEnabled: Boolean = false,
onPlayPrevious: () -> Unit = {},
onPlayNext: () -> Unit = {},
onCast: () -> Unit = {},
onOpenInExternalPlayer: () -> Unit = {},
onPictureInPicture: () -> Unit = {},
onShowSettings: (Int) -> Unit,
onClose: () -> Unit
) {
val panelColor = Color(0x8010141A)
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxWidth().height(160.dp).background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.72f), Color.Transparent))))
Box(modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(220.dp).background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.86f)))))
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(if (deviceType == DeviceType.TV) 28.dp else 16.dp)
) {
Row(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(horizontal = if (deviceType == DeviceType.TV) 4.dp else 0.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
color = Color.White,
fontSize = if(deviceType == DeviceType.TV) 24.sp else 18.sp,
fontWeight = FontWeight.Black,
maxLines = 1
)
streamDetailLine?.takeIf { it.isNotBlank() }?.let { detail ->
Spacer(modifier = Modifier.height(3.dp))
Text(
text = detail,
color = Color.White.copy(alpha = 0.82f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = episodeMetaLine ?: buildString {
if (!content.releaseInfo.isNullOrBlank()) append(content.releaseInfo)
if (!content.runtime.isNullOrBlank()) {
if (isNotEmpty()) append(" ")
append(content.runtime)
}
},
color = Color.White.copy(alpha = 0.68f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
maxLines = 1
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PlayerControlBtn(FluxaIcons.OpenInNew, deviceType) { onOpenInExternalPlayer() }
PlayerControlBtn(FluxaIcons.AspectRatio, deviceType) { onToggleAspect() }
if (introDbMarkingEnabled) {
PlayerControlBtn(FluxaIcons.BookmarkBorder, deviceType) { onShowSettings(5) }
}
}
}
Row(
modifier = Modifier
.align(Alignment.Center)
.clip(RoundedCornerShape(999.dp))
.background(Color.Black.copy(alpha = 0.22f))
.padding(horizontal = if(deviceType == DeviceType.TV) 18.dp else 12.dp, vertical = if(deviceType == DeviceType.TV) 14.dp else 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(if(deviceType == DeviceType.TV) 20.dp else 14.dp)
) {
SeekIconButton(FluxaIcons.SkipPrevious, deviceType) {
if (hasPreviousEpisode) onPlayPrevious()
}
Box(
modifier = Modifier
.size(if(deviceType == DeviceType.TV) 78.dp else 60.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.46f))
.border(1.dp, Color.White.copy(alpha = 0.12f), CircleShape)
.clickable { onPlayPause() }
.then(if(deviceType == DeviceType.TV) Modifier.focusRequester(playPauseFocusRequester).focusProperties { down = seekbarFocusRequester }.focusable() else Modifier),
contentAlignment = Alignment.Center
) {
Icon(if (isPlaying) FluxaIcons.Pause else FluxaIcons.PlayArrow, null, tint = Color.White, modifier = Modifier.size(if(deviceType == DeviceType.TV) 38.dp else 28.dp))
}
SeekIconButton(FluxaIcons.SkipNext, deviceType) {
if (hasNextEpisode) onPlayNext()
}
}
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = if(deviceType == DeviceType.TV) 4.dp else 0.dp)
.clip(RoundedCornerShape(18.dp))
.background(panelColor)
.padding(horizontal = if(deviceType == DeviceType.TV) 18.dp else 14.dp, vertical = if(deviceType == DeviceType.TV) 14.dp else 12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
text = formatTime(if (isScrubbing) scrubPosition else position),
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.width(if (duration >= 3600000) 88.dp else 70.dp)
)
Box(modifier = Modifier.weight(1f)) {
TVSeekbar(position, duration, bufferedFraction, onSeek, seekbarFocusRequester, playPauseFocusRequester, onScrubbingChange, seekForwardMs, seekBackwardMs, chapters, isPlaying, onScrubSeek)
}
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.width(if (duration >= 3600000) 120.dp else 100.dp)
) {
Text(
text = when {
isSwitchingAudioSource -> playerText(lang, "english_source")
hasStartedPlaying && duration > 0 -> formatTime(duration)
else -> playerStatusText(lang, detailedStatus)
},
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.End
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (supportsTrackSettings) {
PlayerControlBtn(FluxaIcons.AudioTrack, deviceType) { onShowSettings(0) }
PlayerControlBtn(if (subtitlesEnabled) FluxaIcons.Subtitles else FluxaIcons.SubtitlesOff, deviceType) { onShowSettings(1) }
}
PlayerControlBtn(FluxaIcons.Speed, deviceType) { onShowSettings(2) }
if (showSourcesButton) {
PlayerControlBtn(FluxaIcons.Storage, deviceType) { onShowSettings(4) }
}
}
}
}
}
}

View file

@ -0,0 +1,389 @@
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.domain.discovery.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Border
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.Glow
import androidx.tv.material3.Surface
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import kotlinx.coroutines.launch
@Composable
internal fun TvSourceSelectionScreen(
meta: Meta,
video: Video?,
videoId: String?,
initialProgress: Long,
lastStreamIndex: Int? = null,
lastStreamUrl: String? = null,
lastStreamTitle: String? = null,
autoSelectSavedSource: Boolean = true,
downloadMode: Boolean = false,
activeProfile: UserProfile?,
viewModel: DetailViewModel,
onBack: () -> Unit,
onStreamSelected: (Stream, List<Stream>, Int, Boolean) -> Unit
) {
val context = LocalContext.current
val vmState by viewModel.uiState.collectAsStateWithLifecycle()
val detail = vmState.detail
val streams = vmState.filteredStreams
val isLoadingStreams = vmState.isLoadingStreams
val availableAddons = vmState.availableAddons
val selectedAddon = vmState.selectedAddon
val userAddons = vmState.userAddons
val scope = rememberCoroutineScope()
val downloadManager = remember(context) { OfflineDownloadManager.getInstance(context) }
var subtitleDialog by remember { mutableStateOf<Pair<Stream, List<OfflineSubtitleOption>>?>(null) }
var loadingDownloadStream by remember { mutableStateOf<Stream?>(null) }
val accent = Color(activeProfile?.safeAccentColorArgb ?: FluxaColors.accentArgb)
val targetId = video?.id ?: videoId ?: meta.id
val title = detail?.name?.takeIf { it.isNotBlank() } ?: meta.name
val logoUrl = (detail?.logo ?: meta.logo)?.takeIf { it.isNotBlank() }
var logoLoadFailed by remember(logoUrl) { mutableStateOf(false) }
var hasFetchedStreams by remember(targetId, activeProfile?.id) { mutableStateOf(false) }
val episodeLine = remember(video, videoId) {
video?.let { ep ->
buildString {
append("S")
append(ep.season ?: 1)
append("E")
append(ep.number ?: 0)
ep.name?.takeIf { it.isNotBlank() }?.let { append(" - ").append(it) }
}
} ?: videoId?.takeIf { meta.type == "series" }
}
val heroImage = video?.thumbnail ?: if (video == null) detail?.background else null
val lang = activeProfile?.safeLanguage ?: "en"
var resumedSavedSource by remember(targetId, lastStreamUrl, lastStreamTitle, lastStreamIndex) { mutableStateOf(false) }
fun startDownloadFlow(stream: Stream) {
if (!stream.isOfflineDownloadable()) {
android.widget.Toast.makeText(context, AppStrings.t(lang, "downloads.unsupported_source"), android.widget.Toast.LENGTH_SHORT).show()
return
}
loadingDownloadStream = stream
scope.launch {
val options = fetchDownloadSubtitleOptions(
viewModel = viewModel,
addons = userAddons,
profile = activeProfile,
type = meta.type,
id = targetId,
stream = stream
)
loadingDownloadStream = null
subtitleDialog = stream to options
}
}
LaunchedEffect(meta.id, meta.type, activeProfile?.id) {
viewModel.loadDetail(meta.type, meta.id, activeProfile)
}
LaunchedEffect(targetId, detail?.id, activeProfile?.id) {
if (meta.type == "series" && targetId == meta.id) return@LaunchedEffect
if (hasFetchedStreams) return@LaunchedEffect
hasFetchedStreams = true
viewModel.fetchStreamsForSelection(meta.type, targetId, context)
}
LaunchedEffect(streams, isLoadingStreams, lastStreamUrl, lastStreamTitle, lastStreamIndex, activeProfile?.safeStreamSourceSelectionMode, activeProfile?.safeStreamSourceRegexPattern, downloadMode) {
if (resumedSavedSource || isLoadingStreams || streams.isEmpty()) return@LaunchedEffect
if (downloadMode) return@LaunchedEffect
if (meta.id.startsWith("cs3:") || targetId.startsWith("cs3:")) {
resumedSavedSource = true
onStreamSelected(streams[0], streams, 0, true)
return@LaunchedEffect
}
val mode = activeProfile?.safeStreamSourceSelectionMode ?: STREAM_SOURCE_MODE_MANUAL
val matchedIndex = if (mode == STREAM_SOURCE_MODE_MANUAL) {
if (!autoSelectSavedSource) return@LaunchedEffect
when {
!lastStreamUrl.isNullOrBlank() -> streams.indexOfFirst { it.playableUrl == lastStreamUrl }
!lastStreamTitle.isNullOrBlank() -> streams.indexOfFirst { it.title == lastStreamTitle }
lastStreamIndex != null && lastStreamIndex in streams.indices -> lastStreamIndex
else -> -1
}
} else {
selectStreamIndex(
streams = streams,
currentVideoId = targetId,
initialStreamIndex = lastStreamIndex ?: 0,
savedUrl = null,
savedTitle = null,
sourceSelectionMode = mode,
regexPattern = activeProfile?.safeStreamSourceRegexPattern,
preferredBingeGroup = null
)
}
if (matchedIndex >= 0) {
resumedSavedSource = true
onStreamSelected(streams[matchedIndex], streams, matchedIndex, true)
}
}
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
if (!heroImage.isNullOrBlank()) {
AsyncImage(
model = heroImage,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.45f), Color.Black.copy(alpha = 0.78f), Color.Black)))
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Brush.horizontalGradient(listOf(Color.Black.copy(alpha = 0.55f), Color.Transparent)))
)
IconButton(
onClick = onBack,
modifier = Modifier
.padding(start = 32.dp, top = 32.dp)
.size(44.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.08f))
) {
Icon(FluxaIcons.ArrowBack, null, tint = Color.White)
}
Row(modifier = Modifier.fillMaxSize().padding(start = 56.dp, end = 56.dp, top = 96.dp, bottom = 40.dp)) {
Column(
modifier = Modifier
.width(420.dp)
.fillMaxHeight()
.padding(end = 32.dp),
verticalArrangement = Arrangement.Center
) {
if (logoUrl != null && !logoLoadFailed) {
AsyncImage(
model = logoUrl,
contentDescription = title,
modifier = Modifier.heightIn(max = 96.dp).widthIn(max = 360.dp),
contentScale = ContentScale.Fit,
alignment = Alignment.CenterStart,
onError = { logoLoadFailed = true }
)
} else {
Text(text = title, color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Black, maxLines = 3, overflow = TextOverflow.Ellipsis)
}
episodeLine?.let {
Spacer(Modifier.height(10.dp))
Text(text = it, color = Color.White.copy(alpha = 0.75f), fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
val synopsis = video?.overview?.takeIf { it.isNotBlank() } ?: detail?.description.orEmpty()
if (synopsis.isNotBlank()) {
Spacer(Modifier.height(14.dp))
Text(text = synopsis, color = Color.White.copy(alpha = 0.62f), fontSize = 14.sp, lineHeight = 20.sp, maxLines = 6, overflow = TextOverflow.Ellipsis)
}
}
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(vertical = 8.dp)) {
item {
TvSourceChip(AppStrings.t(lang, "auto.all_24e2815b"), selectedAddon == null) { viewModel.setSelectedAddon(null) }
}
items(availableAddons, key = { it }) { addon ->
TvSourceChip(addon, selectedAddon == addon) { viewModel.setSelectedAddon(addon) }
}
}
Spacer(Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(20.dp))
.background(Color.Black.copy(alpha = 0.45f))
.padding(20.dp)
) {
when {
isLoadingStreams && streams.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = accent)
}
}
!isLoadingStreams && streams.isEmpty() -> {
Text(
text = AppStrings.t(lang, "auto.no_sources_found_3019f12c"),
color = Color.White.copy(alpha = 0.58f),
fontSize = 14.sp,
modifier = Modifier.padding(vertical = 24.dp)
)
}
else -> {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
itemsIndexed(streams, key = { i, s -> s.playableUrl ?: "${s.title.orEmpty()}$i" }) { index, stream ->
TvSourceStreamRow(
stream = stream,
isLoading = loadingDownloadStream == stream,
onClick = {
if (!downloadMode) onStreamSelected(stream, streams, index, false) else startDownloadFlow(stream)
}
)
}
}
}
}
}
}
}
}
subtitleDialog?.let { (stream, options) ->
AlertDialog(
onDismissRequest = { subtitleDialog = null },
title = { Text(AppStrings.t(lang, "downloads.subtitles_title")) },
text = {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.heightIn(max = 320.dp)) {
item {
SourceSubtitleOptionRow(
label = AppStrings.t(lang, "downloads.no_subtitle"),
onClick = {
subtitleDialog = null
scope.launch {
enqueueOfflineDownload(downloadManager, activeProfile, meta, video, videoId, stream, null, context)
onBack()
}
}
)
}
items(options, key = { it.url }) { option ->
SourceSubtitleOptionRow(
label = option.label,
onClick = {
subtitleDialog = null
scope.launch {
enqueueOfflineDownload(downloadManager, activeProfile, meta, video, videoId, stream, option, context)
onBack()
}
}
)
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { subtitleDialog = null }) {
Text(AppStrings.t(lang, "common.cancel"))
}
}
)
}
}
@Composable
private fun TvSourceChip(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
onClick = onClick,
modifier = Modifier.height(36.dp),
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(18.dp)),
colors = ClickableSurfaceDefaults.colors(
containerColor = if (selected) Color.White else Color.White.copy(alpha = 0.08f),
contentColor = if (selected) Color.Black else Color.White,
focusedContainerColor = if (selected) Color.White else Color.White.copy(alpha = 0.08f),
focusedContentColor = if (selected) Color.Black else Color.White
),
border = ClickableSurfaceDefaults.border(
border = Border(androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.12f))),
focusedBorder = Border(androidx.compose.foundation.BorderStroke(2.dp, Color.White))
),
glow = ClickableSurfaceDefaults.glow(glow = Glow.None, focusedGlow = Glow.None, pressedGlow = Glow.None),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f)
) {
Box(modifier = Modifier.fillMaxHeight().padding(horizontal = 18.dp), contentAlignment = Alignment.Center) {
Text(label, color = if (selected) Color.Black else Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
private fun TvSourceStreamRow(stream: Stream, isLoading: Boolean, onClick: () -> Unit) {
var focused by remember { mutableStateOf(false) }
val sourceName = stream.streamSourceHeader()
val rawTitle = stream.streamRawBody()
Surface(
onClick = onClick,
modifier = Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused },
shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp)),
colors = ClickableSurfaceDefaults.colors(containerColor = if (focused) Color.White.copy(alpha = 0.14f) else Color.White.copy(alpha = 0.04f)),
border = ClickableSurfaceDefaults.border(
focusedBorder = Border(androidx.compose.foundation.BorderStroke(2.dp, Color.White)),
border = Border(androidx.compose.foundation.BorderStroke(1.dp, Color.White.copy(alpha = 0.1f)))
),
glow = ClickableSurfaceDefaults.glow(glow = Glow.None, focusedGlow = Glow.None, pressedGlow = Glow.None),
scale = ClickableSurfaceDefaults.scale(focusedScale = 1f)
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(sourceName, color = Color.White, fontSize = 14.sp, lineHeight = 17.sp, fontWeight = FontWeight.Black, maxLines = 2, overflow = TextOverflow.Ellipsis)
rawTitle?.let {
AddonStreamBodyText(text = it, bodyMaxLines = 4)
}
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), color = Color.White, strokeWidth = 2.dp)
}
}
}
}

View file

@ -1,6 +1,7 @@
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
@ -65,7 +66,7 @@ internal fun MobileDetailInfoSection(
isInWatchlist: Boolean,
onToggleWatchlist: () -> Unit,
onFeedback: (Boolean) -> Unit,
accentColor: Color = Color(0xFFE50914)
accentColor: Color = FluxaColors.accent
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
val logoUrl = detail?.logo?.takeIf { it.isNotBlank() }

View file

@ -1,5 +1,6 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
@ -285,7 +286,7 @@ private fun GridCatalogCard(
modifier = Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(Color(0xFFE50914))
.background(FluxaColors.progressFill)
)
}
}

View file

@ -2,79 +2,35 @@
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.layout.ExperimentalLayoutApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.domain.discovery.*
import android.content.Intent
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.*
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
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.zIndex
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items as mobileGridItems
import androidx.tv.material3.*
import coil3.compose.AsyncImage
import coil3.decode.StaticImageDecoder
import coil3.imageLoader
import coil3.request.ImageRequest
import coil3.request.allowHardware
import coil3.request.crossfade
import coil3.size.Size
import coil3.size.Precision
import coil3.size.Scale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.distinctUntilChanged
import androidx.compose.runtime.snapshotFlow
import java.text.SimpleDateFormat
import java.util.*
import kotlin.math.abs
@Composable
internal fun MobileMovieCard(
@ -92,7 +48,11 @@ internal fun MobileMovieCard(
isContinueWatchingCard: Boolean = false,
loadArtwork: Boolean = true
) {
val deviceType = LocalDeviceType.current
val context = LocalContext.current
val density = LocalDensity.current
val lang = profile?.safeLanguage ?: "en"
val widthPreset = profile?.safePosterWidthPreset ?: "medium"
val effectiveCardLayout = if (meta.type == "catalog_folder") {
when (meta.reason) {
"wide" -> "horizontal"
@ -103,164 +63,104 @@ internal fun MobileMovieCard(
} else {
cardLayout
}
val isHorizontal = effectiveCardLayout == "horizontal"
val isSquare = effectiveCardLayout == "square"
val isEpisodeStyle = effectiveCardLayout == "episode"
val isHorizontal = effectiveCardLayout == "horizontal" || isEpisodeStyle
val isSquare = effectiveCardLayout == "square"
val isCatalogFolder = meta.type == "catalog_folder"
val isProgressCard = meta.isUpNextContinueItem() || ((meta.timeOffset ?: 0L) > 0L && (meta.duration ?: 0L) > 0L)
if (deviceType == DeviceType.Mobile && isCatalogFolder && !isContinueWatchingCard && topTenRank == null && !isProgressCard) {
LeanCatalogFolderCard(
meta = meta,
profile = profile,
cardLayout = effectiveCardLayout,
cardScale = cardScale,
loadArtwork = loadArtwork,
onClick = onClick
)
return
}
if (deviceType == DeviceType.Mobile && isContinueWatchingCard && profile?.safeContinueWatchingHideTitles != true && topTenRank == null) {
LeanContinueWatchingCard(
meta = meta,
profile = profile,
cardLayout = effectiveCardLayout,
artworkPreference = artworkPreference,
cardScale = cardScale,
loadArtwork = loadArtwork,
onClick = onClick,
onProgressActions = onProgressActions
)
return
}
if (deviceType == DeviceType.Mobile && topTenRank != null && !isHorizontal && !isSquare && !isEpisodeStyle &&
!isCatalogFolder && !isContinueWatchingCard && !isProgressCard) {
LeanTopTenPosterCard(
meta = meta,
profile = profile,
rank = topTenRank,
cardScale = cardScale,
loadArtwork = loadArtwork,
onClick = onClick
)
return
}
// Fast path for the common case on mobile: plain movie/show cards do not need the rich
// MovieCardContent overlay/logo stack. This also covers landscape poster mode.
if (deviceType == DeviceType.Mobile && !isEpisodeStyle &&
!isCatalogFolder && !isContinueWatchingCard && topTenRank == null && !isProgressCard) {
LeanPlainMovieCard(
meta = meta,
profile = profile,
cardLayout = effectiveCardLayout,
cardScale = cardScale,
loadArtwork = loadArtwork,
onClick = onClick
)
return
}
val density = LocalDensity.current
var isFocused by remember { mutableStateOf(false) }
var showForgetOverlay by remember { mutableStateOf(false) }
val radius = if (isContinueWatchingCard) 0.dp else posterCornerRadius(profile?.safeCardCornerPreset ?: "soft")
val isUpcomingRelease = remember(meta.released) { isUpcoming(meta.released) }
val animationDuration = when {
profile?.safeAnimationsEnabled == false -> 0
else -> FluxaDimensions.AnimDuration.cardFocusScale
}
val cardInteractionSource = remember { MutableInteractionSource() }
val focusedScale = FluxaDimensions.cardFocusedScale
val hidePosterTitles = profile?.safePosterHideTitles == true || meta.hideTitle == true
val hideContinueWatchingTitles = isContinueWatchingCard && profile?.safeContinueWatchingHideTitles == true
val showBottomTitle = deviceType == DeviceType.Mobile &&
!hideContinueWatchingTitles &&
(isProgressCard || (!isEpisodeStyle && !hidePosterTitles))
val lang = profile?.safeLanguage ?: "en"
val cardIndication = if (deviceType == DeviceType.Mobile) null else LocalIndication.current
// Continue-watching labels (continueWatchingEpisodeTitle compiles a Regex per call) are computed
// only for progress/continue-watching cards; skipped entirely for plain posters.
val needsContinueLabels = isProgressCard || isContinueWatchingCard
val episodeLabel = remember(meta.id, meta.lastVideoId, meta.lastEpisodeName, needsContinueLabels) {
if (needsContinueLabels) continueWatchingEpisodeLabel(meta) else null
val showTitle = !hideContinueWatchingTitles && !(profile?.safePosterHideTitles == true || meta.hideTitle == true)
val width = (when {
isEpisodeStyle -> FluxaDimensions.EpisodeCard.mobileWidth
isHorizontal -> horizontalCardWidth(widthPreset, DeviceType.Mobile)
else -> posterCardWidth(widthPreset)
}) * cardScale
val imageHeight = (when {
isEpisodeStyle -> FluxaDimensions.EpisodeCard.mobileHeight
isHorizontal -> horizontalCardHeight(widthPreset, DeviceType.Mobile)
isSquare -> posterCardWidth(widthPreset)
else -> posterCardHeight(widthPreset)
}) * cardScale
val artwork = remember(meta.id, meta.poster, meta.background, meta.continueWatchingBackground, effectiveCardLayout, artworkPreference, isContinueWatchingCard) {
when {
isEpisodeStyle -> {
val seriesArtwork = when (artworkPreference) {
"episode" -> meta.continueWatchingBackground
"poster" -> meta.poster
"background" -> meta.background
else -> meta.continueWatchingBackground
}
if (meta.type == "series" || meta.type == "tv" || meta.type == "anime") seriesArtwork ?: meta.background else meta.background
}
isContinueWatchingCard -> when (artworkPreference) {
"poster" -> meta.poster ?: meta.continueWatchingBackground ?: meta.background
"background" -> meta.background ?: meta.continueWatchingBackground ?: meta.poster
else -> meta.continueWatchingBackground ?: meta.background ?: meta.poster
}
isHorizontal -> preferredHorizontalArtwork(meta) ?: meta.poster
else -> meta.poster
}
}
val continueEpisodeTitle = remember(meta.id, meta.lastEpisodeName, needsContinueLabels) {
if (needsContinueLabels) continueWatchingEpisodeTitle(meta) else null
val requestWidth = if (isEpisodeStyle) 512 else if (isCatalogFolder) { if (isHorizontal) 384 else 224 } else { if (isHorizontal) 512 else 288 }
val requestHeight = if (isEpisodeStyle) 288 else if (isCatalogFolder) {
when { isHorizontal -> 216; isSquare -> 224; else -> 336 }
} else {
when { isHorizontal -> 288; isSquare -> 288; else -> 432 }
}
val continueProgressLabel = remember(meta.id, meta.timeOffset, meta.duration, lang, needsContinueLabels) {
if (needsContinueLabels) continueWatchingEpisodeProgressLabel(meta, lang) else null
val request = remember(context, artwork, requestWidth, requestHeight, isCatalogFolder) {
val builder = ImageRequest.Builder(context)
.data(artwork)
.crossfade(false)
.memoryCacheKey(homeArtworkMemoryCacheKey(artwork, requestWidth, requestHeight))
.diskCacheKey(artwork)
.size(requestWidth, requestHeight)
if (isCatalogFolder) {
builder.allowHardware(false).precision(Precision.INEXACT).scale(Scale.FILL)
if (!artwork.isNullOrSvgArtwork()) {
builder.decoderFactory(StaticImageDecoder.Factory())
}
}
builder.build()
}
var failed by remember(meta.id, artwork) { mutableStateOf(artwork.isNullOrBlank()) }
val showLogo = isHorizontal && !isEpisodeStyle && showHorizontalLogo && !isCatalogFolder && !isContinueWatchingCard && (meta.timeOffset ?: 0L) <= 0L && !meta.logo.isNullOrBlank()
val logoRequest = remember(meta.logo, showLogo) {
if (!showLogo) null else ImageRequest.Builder(context)
.data(meta.logo)
.crossfade(false)
.memoryCacheKey("home-logo:${meta.logo}")
.diskCacheKey(meta.logo)
.build()
}
val widthPreset = profile?.safePosterWidthPreset ?: "medium"
val width = remember(effectiveCardLayout, cardScale, widthPreset, deviceType) {
(if (deviceType == DeviceType.TV) {
when { isEpisodeStyle -> FluxaDimensions.EpisodeCard.tvWidth; isHorizontal -> horizontalCardWidth(widthPreset, DeviceType.TV); else -> FluxaDimensions.TvPosterCard.width }
} else {
when { isEpisodeStyle -> FluxaDimensions.EpisodeCard.mobileWidth; isHorizontal -> horizontalCardWidth(widthPreset, DeviceType.Mobile); else -> posterCardWidth(widthPreset) }
}) * cardScale
val progress = if (isProgressCard) ((meta.timeOffset ?: 0L).toFloat() / (meta.duration ?: 1L).toFloat()).coerceIn(0f, 1f) else 0f
val secondaryText = when {
isCatalogFolder -> meta.releaseInfo?.take(4) ?: ""
isProgressCard -> continueWatchingEpisodeLabel(meta).orEmpty()
else -> meta.releaseInfo?.take(4) ?: meta.released?.take(4) ?: ""
}
val imageHeight = remember(effectiveCardLayout, cardScale, widthPreset, deviceType) {
(if (deviceType == DeviceType.TV) {
when { isEpisodeStyle -> FluxaDimensions.EpisodeCard.tvHeight; isHorizontal -> horizontalCardHeight(widthPreset, DeviceType.TV); else -> FluxaDimensions.TvPosterCard.height }
} else {
when { isEpisodeStyle -> FluxaDimensions.EpisodeCard.mobileHeight; isHorizontal -> horizontalCardHeight(widthPreset, DeviceType.Mobile); isSquare -> posterCardWidth(widthPreset); else -> posterCardHeight(widthPreset) }
}) * cardScale
}
val metaHeight = if (showBottomTitle) { if (isProgressCard && !episodeLabel.isNullOrBlank()) FluxaDimensions.cardMetaBarWithEpisodeLabelHeight else FluxaDimensions.cardMetaBarHeight } else 0.dp
val height = imageHeight + metaHeight
val rankBase = if (isHorizontal || isEpisodeStyle) imageHeight else width
val rankBase = if (isHorizontal) imageHeight else width
val rankNumberBoxWidth = topTenRank?.let { r -> when { r >= 10 -> rankBase * 1.24f; r == 1 -> rankBase * 0.62f; else -> rankBase * 0.82f } } ?: 0.dp
val rankPosterOverlap = topTenRank?.let { r -> when { r >= 10 -> rankBase * 0.16f; r == 1 -> rankBase * 0.13f; else -> rankBase * 0.24f } } ?: 0.dp
val outerWidth = if (topTenRank != null) rankNumberBoxWidth + width - rankPosterOverlap else width
val rankFontSize = remember(topTenRank, imageHeight, isHorizontal, isEpisodeStyle, density) {
if (topTenRank != null) with(density) { (imageHeight.toPx() * if (isHorizontal || isEpisodeStyle) 0.86f else 0.90f).toSp() } else 0.sp
}
// Focus scale is TV-only. Calling animateFloatAsState only on TV avoids per-card animation
// state on mobile. deviceType is constant per app, so the conditional @Composable call is safe.
val focusLayerModifier = if (deviceType == DeviceType.TV) {
val scale by animateFloatAsState(
targetValue = if (isFocused) focusedScale else 1.0f,
animationSpec = tween(animationDuration),
label = "scale"
)
Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.zIndex(if (isFocused) 10f else 1f)
} else {
Modifier
val rankFontSize = remember(topTenRank, imageHeight, isHorizontal, density) {
if (topTenRank != null) with(density) { (imageHeight.toPx() * if (isHorizontal) 0.86f else 0.90f).toSp() } else 0.sp
}
Box(
modifier = Modifier
.width(outerWidth)
.height(height)
.then(focusLayerModifier)
.height(imageHeight + if (showTitle) FluxaDimensions.cardMetaBarHeight else 0.dp)
.combinedClickable(
interactionSource = cardInteractionSource,
indication = cardIndication,
onClick = {
if (showForgetOverlay) {
showForgetOverlay = false
} else {
onClick()
}
},
interactionSource = null,
indication = null,
onClick = onClick,
onLongClick = {
if (onProgressActions != null && (meta.timeOffset ?: 0L) > 0L) {
onProgressActions()
} else if (onForgetProgress != null && (meta.timeOffset ?: 0L) > 0L) {
showForgetOverlay = true
}
if ((meta.timeOffset ?: 0L) > 0L) onProgressActions?.invoke()
}
)
) {
@ -269,8 +169,7 @@ internal fun MobileMovieCard(
modifier = Modifier
.align(Alignment.TopStart)
.width(rankNumberBoxWidth)
.height(imageHeight)
.zIndex(0f),
.height(imageHeight),
contentAlignment = Alignment.CenterEnd
) {
TopTenRankNumber(
@ -282,526 +181,85 @@ internal fun MobileMovieCard(
rank >= 10 -> 0.dp
else -> 3.dp
},
y = if (isHorizontal || isEpisodeStyle) 1.dp else 2.dp
y = if (isHorizontal) 1.dp else 2.dp
)
)
}
}
Column(
modifier = Modifier
.width(width)
.height(height)
.align(Alignment.TopEnd)
.zIndex(1f)
) {
Column(modifier = Modifier.align(Alignment.TopEnd).width(width)) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(imageHeight)
// No clip here: MovieCardContent already clips with the same radius (avoid a double graphics layer).
.background(if (isContinueWatchingCard || isEpisodeStyle) FluxaColors.surfaceCard else Color.White.copy(alpha = FluxaDimensions.Alpha.emptyCardBackground)),
contentAlignment = Alignment.Center
) {
MovieCardContent(
movie = meta,
isUpcoming = isUpcomingRelease,
isFocused = isFocused,
cardLayout = effectiveCardLayout,
artworkPreference = artworkPreference,
cornerRadius = radius,
hideTitles = hidePosterTitles,
showHorizontalLogo = showHorizontalLogo,
lang = profile?.safeLanguage,
isContinueWatchingCard = isContinueWatchingCard,
hideContinueWatchingTitles = hideContinueWatchingTitles,
contentWidth = width,
contentHeight = imageHeight,
loadArtwork = loadArtwork
)
if (isFocused && meta.focusGlowEnabled == true) {
Box(
modifier = Modifier
.matchParentSize()
.border(2.dp, Color(profile?.colorArgb ?: 0xFFFFFFFF.toInt()), RoundedCornerShape(radius))
)
}
}
if (showBottomTitle) {
Text(
text = meta.name,
color = Color.White,
fontSize = FluxaDimensions.CardText.titleSize,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp)
)
val secondaryText = when {
isCatalogFolder -> ""
isProgressCard -> episodeLabel.orEmpty()
else -> meta.releaseInfo?.take(4) ?: meta.released?.take(4) ?: ""
}
if (!secondaryText.isNullOrBlank()) {
Text(
text = secondaryText,
color = Color.White.copy(alpha = FluxaDimensions.Alpha.cardSubtitle),
fontSize = FluxaDimensions.CardText.subtitleSize,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 1.dp)
)
}
}
}
AnimatedVisibility(
visible = showForgetOverlay,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.zIndex(2f)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(Color(0xE0181B20))
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(999.dp))
.clickable {
showForgetOverlay = false
onForgetProgress?.invoke()
}
.padding(horizontal = 10.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(FluxaIcons.DeleteOutline, null, tint = Color.White, modifier = Modifier.size(16.dp))
Text(AppStrings.t(lang, "common.forget"), color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
}
}
// Lean card for plain movie/show cards: bypasses the heavy MovieCardContent/CardBody chain with
// just Column + Box + AsyncImage + 2 Text, cutting per-card compose/allocation cost on fling.
@Composable
private fun LeanPlainMovieCard(
meta: Meta,
profile: UserProfile?,
cardLayout: String,
cardScale: Float,
loadArtwork: Boolean,
onClick: () -> Unit
) {
val context = LocalContext.current
val widthPreset = profile?.safePosterWidthPreset ?: "medium"
val isHorizontal = cardLayout == "horizontal"
val isSquare = cardLayout == "square"
val width = (if (isHorizontal) horizontalCardWidth(widthPreset, DeviceType.Mobile) else posterCardWidth(widthPreset)) * cardScale
val imageHeight = (when {
isHorizontal -> horizontalCardHeight(widthPreset, DeviceType.Mobile)
isSquare -> posterCardWidth(widthPreset)
else -> posterCardHeight(widthPreset)
}) * cardScale
val showTitle = !(profile?.safePosterHideTitles == true || meta.hideTitle == true)
val artwork = remember(meta.id, meta.poster, meta.background, cardLayout) {
if (isHorizontal) preferredHorizontalArtwork(meta) ?: meta.poster else meta.poster
}
val requestWidth = if (isHorizontal) 512 else 288
val requestHeight = when {
isHorizontal -> 288
isSquare -> 288
else -> 432
}
val request = remember(artwork, requestWidth, requestHeight) {
ImageRequest.Builder(context)
.data(artwork)
.crossfade(false)
.memoryCacheKey(homeArtworkMemoryCacheKey(artwork, requestWidth, requestHeight))
.diskCacheKey(artwork)
.size(requestWidth, requestHeight)
.build()
}
var failed by remember(meta.id, artwork) { mutableStateOf(artwork.isNullOrBlank()) }
Column(
modifier = Modifier
.width(width)
.clickable(
interactionSource = null,
indication = null,
onClick = onClick
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(imageHeight)
.background(Color.White.copy(alpha = FluxaDimensions.Alpha.emptyCardBackground)),
contentAlignment = Alignment.Center
) {
if (loadArtwork && !failed) {
AsyncImage(
model = request,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
onError = { failed = true }
)
} else {
Text(
text = meta.coverEmoji?.takeIf { it.isNotBlank() } ?: meta.name.take(1).uppercase(),
color = Color.White.copy(alpha = if (meta.coverEmoji.isNullOrBlank()) FluxaDimensions.Alpha.coverFallbackText else FluxaDimensions.Alpha.coverEmoji),
fontSize = if (meta.coverEmoji.isNullOrBlank()) FluxaDimensions.CardText.coverFallbackSize else FluxaDimensions.CardText.coverEmojiSize,
fontWeight = FontWeight.Black
)
}
}
if (showTitle) {
Text(
text = meta.name,
color = Color.White,
fontSize = FluxaDimensions.CardText.titleSize,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp)
)
val secondary = meta.releaseInfo?.take(4) ?: meta.released?.take(4) ?: ""
if (secondary.isNotBlank()) {
Text(
text = secondary,
color = Color.White.copy(alpha = FluxaDimensions.Alpha.cardSubtitle),
fontSize = FluxaDimensions.CardText.subtitleSize,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 1.dp)
)
}
}
}
}
@Composable
private fun LeanCatalogFolderCard(
meta: Meta,
profile: UserProfile?,
cardLayout: String,
cardScale: Float,
loadArtwork: Boolean,
onClick: () -> Unit
) {
val context = LocalContext.current
val widthPreset = profile?.safePosterWidthPreset ?: "medium"
val isHorizontal = cardLayout == "horizontal"
val isSquare = cardLayout == "square"
val width = when {
isHorizontal -> horizontalCardWidth(widthPreset, DeviceType.Mobile)
else -> posterCardWidth(widthPreset)
} * cardScale
val imageHeight = when {
isHorizontal -> horizontalCardHeight(widthPreset, DeviceType.Mobile)
isSquare -> posterCardWidth(widthPreset)
else -> posterCardHeight(widthPreset)
} * cardScale
val showTitle = !(profile?.safePosterHideTitles == true || meta.hideTitle == true)
val requestWidth = if (isHorizontal) 384 else 224
val requestHeight = when {
isHorizontal -> 216
isSquare -> 224
else -> 336
}
val request = remember(meta.poster, requestWidth, requestHeight) {
val builder = ImageRequest.Builder(context)
.data(meta.poster)
.crossfade(false)
.allowHardware(false)
.memoryCacheKey(homeArtworkMemoryCacheKey(meta.poster, requestWidth, requestHeight))
.diskCacheKey(meta.poster)
.precision(Precision.INEXACT)
.scale(Scale.FILL)
.size(requestWidth, requestHeight)
if (!meta.poster.isNullOrSvgArtwork()) {
builder.decoderFactory(StaticImageDecoder.Factory())
}
builder.build()
}
var failed by remember(meta.id, meta.poster) { mutableStateOf(meta.poster.isNullOrBlank()) }
Column(
modifier = Modifier
.width(width)
.clickable(
interactionSource = null,
indication = null,
onClick = onClick
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(imageHeight)
.background(Color.White.copy(alpha = FluxaDimensions.Alpha.emptyCardBackground)),
contentAlignment = Alignment.Center
) {
if (loadArtwork && !failed) {
AsyncImage(
model = request,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
filterQuality = FilterQuality.Low,
onError = { failed = true }
)
} else {
Text(
text = meta.coverEmoji?.takeIf { it.isNotBlank() } ?: meta.name.take(1).uppercase(),
color = Color.White.copy(alpha = if (meta.coverEmoji.isNullOrBlank()) FluxaDimensions.Alpha.coverFallbackText else FluxaDimensions.Alpha.coverEmoji),
fontSize = if (meta.coverEmoji.isNullOrBlank()) FluxaDimensions.CardText.coverFallbackSize else FluxaDimensions.CardText.coverEmojiSize,
fontWeight = FontWeight.Black
)
}
}
if (showTitle) {
Text(
text = meta.name,
color = Color.White,
fontSize = FluxaDimensions.CardText.titleSize,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp)
)
val secondary = meta.releaseInfo?.take(4) ?: ""
if (secondary.isNotBlank()) {
Text(
text = secondary,
color = Color.White.copy(alpha = FluxaDimensions.Alpha.cardSubtitle),
fontSize = FluxaDimensions.CardText.subtitleSize,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 1.dp)
)
}
}
}
}
@Composable
private fun LeanContinueWatchingCard(
meta: Meta,
profile: UserProfile?,
cardLayout: String,
artworkPreference: String?,
cardScale: Float,
loadArtwork: Boolean,
onClick: () -> Unit,
onProgressActions: (() -> Unit)?
) {
val context = LocalContext.current
val widthPreset = profile?.safePosterWidthPreset ?: "medium"
val isEpisodeStyle = cardLayout == "episode"
val isHorizontal = cardLayout == "horizontal" || isEpisodeStyle
val width = (if (isEpisodeStyle) FluxaDimensions.EpisodeCard.mobileWidth else if (isHorizontal) horizontalCardWidth(widthPreset, DeviceType.Mobile) else posterCardWidth(widthPreset)) * cardScale
val imageHeight = (if (isEpisodeStyle) FluxaDimensions.EpisodeCard.mobileHeight else if (isHorizontal) horizontalCardHeight(widthPreset, DeviceType.Mobile) else posterCardHeight(widthPreset)) * cardScale
val lang = profile?.safeLanguage ?: "en"
val progress = ((meta.timeOffset ?: 0L).toFloat() / (meta.duration ?: 1L).toFloat()).coerceIn(0f, 1f)
val isProgressCard = meta.isUpNextContinueItem() || ((meta.timeOffset ?: 0L) > 0L && (meta.duration ?: 0L) > 0L)
val title = meta.name
val secondary = remember(meta.id, meta.lastVideoId, meta.lastEpisodeName, isProgressCard) {
if (isProgressCard) continueWatchingEpisodeLabel(meta).orEmpty() else meta.releaseInfo?.take(4).orEmpty()
}
val artwork = remember(meta.id, meta.poster, meta.background, meta.continueWatchingBackground, artworkPreference) {
when (artworkPreference) {
"poster" -> meta.poster ?: meta.continueWatchingBackground ?: meta.background
"background" -> meta.background ?: meta.continueWatchingBackground ?: meta.poster
else -> meta.continueWatchingBackground ?: meta.background ?: meta.poster
}
}
val requestWidth = if (isHorizontal) 512 else 288
val requestHeight = if (isHorizontal) 288 else 432
val request = remember(context, artwork, requestWidth, requestHeight) {
ImageRequest.Builder(context)
.data(artwork)
.crossfade(false)
.memoryCacheKey(homeArtworkMemoryCacheKey(artwork, requestWidth, requestHeight))
.diskCacheKey(artwork)
.size(requestWidth, requestHeight)
.build()
}
Column(
modifier = Modifier
.width(width)
.combinedClickable(
interactionSource = null,
indication = null,
onClick = onClick,
onLongClick = {
if ((meta.timeOffset ?: 0L) > 0L) onProgressActions?.invoke()
}
)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(imageHeight)
.background(Color(0xFF141922)),
contentAlignment = Alignment.Center
) {
if (loadArtwork) {
AsyncImage(
model = request,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
if (!meta.isUpNextContinueItem() && progress > 0f) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp)
.height(FluxaDimensions.cardProgressBarHeight)
.clip(RoundedCornerShape(999.dp))
.background(Color.Black.copy(alpha = 0.50f))
) {
Box(
modifier = Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(Color(0xFFE50914))
)
}
}
if (meta.isUpNextContinueItem()) {
Text(
text = AppStrings.t(lang, "auto.up_next"),
color = Color.White,
fontSize = FluxaDimensions.CardText.subtitleSize,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.background(Color.Black.copy(alpha = FluxaDimensions.Alpha.upNextBadge))
.padding(horizontal = 8.dp, vertical = 5.dp)
)
}
}
Text(
text = title,
color = Color.White,
fontSize = FluxaDimensions.CardText.titleSize,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp)
)
if (secondary.isNotBlank()) {
Text(
text = secondary,
color = Color.White.copy(alpha = FluxaDimensions.Alpha.cardSubtitle),
fontSize = FluxaDimensions.CardText.subtitleSize,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 1.dp)
)
}
}
}
@Composable
private fun LeanTopTenPosterCard(
meta: Meta,
profile: UserProfile?,
rank: Int,
cardScale: Float,
loadArtwork: Boolean,
onClick: () -> Unit
) {
val context = LocalContext.current
val density = LocalDensity.current
val widthPreset = profile?.safePosterWidthPreset ?: "medium"
val width = posterCardWidth(widthPreset) * cardScale
val imageHeight = posterCardHeight(widthPreset) * cardScale
val hideTitle = profile?.safePosterHideTitles == true || meta.hideTitle == true
val request = remember(context, meta.poster) {
ImageRequest.Builder(context)
.data(meta.poster)
.crossfade(false)
.memoryCacheKey(homeArtworkMemoryCacheKey(meta.poster, 288, 432))
.diskCacheKey(meta.poster)
.size(288, 432)
.build()
}
val rankBase = width
val rankNumberBoxWidth = when {
rank >= 10 -> rankBase * 1.24f
rank == 1 -> rankBase * 0.62f
else -> rankBase * 0.82f
}
val rankPosterOverlap = when {
rank >= 10 -> rankBase * 0.16f
rank == 1 -> rankBase * 0.13f
else -> rankBase * 0.24f
}
val outerWidth = rankNumberBoxWidth + width - rankPosterOverlap
val rankFontSize = remember(imageHeight, density) { with(density) { (imageHeight.toPx() * 0.90f).toSp() } }
Box(
modifier = Modifier
.width(outerWidth)
.height(imageHeight + if (hideTitle) 0.dp else 42.dp)
.clickable(
interactionSource = null,
indication = null,
onClick = onClick
)
) {
Box(
modifier = Modifier
.align(Alignment.TopStart)
.width(rankNumberBoxWidth)
.height(imageHeight),
contentAlignment = Alignment.CenterEnd
) {
TopTenRankNumber(
rank = rank,
fontSize = rankFontSize,
modifier = Modifier.offset(
x = when {
rank == 1 -> 8.dp
rank >= 10 -> 0.dp
else -> 3.dp
},
y = 2.dp
)
)
}
Column(
modifier = Modifier
.align(Alignment.TopEnd)
.width(width)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(imageHeight)
.background(Color.White.copy(alpha = FluxaDimensions.Alpha.emptyCardBackground))
) {
if (loadArtwork) {
if (loadArtwork && !failed) {
AsyncImage(
model = request,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
contentScale = ContentScale.Crop,
filterQuality = if (isCatalogFolder) FilterQuality.Low else FilterQuality.High,
onError = { failed = true }
)
} else if (!isContinueWatchingCard && !isEpisodeStyle) {
Text(
text = meta.coverEmoji?.takeIf { it.isNotBlank() } ?: meta.name.take(1).uppercase(),
color = Color.White.copy(alpha = if (meta.coverEmoji.isNullOrBlank()) FluxaDimensions.Alpha.coverFallbackText else FluxaDimensions.Alpha.coverEmoji),
fontSize = if (meta.coverEmoji.isNullOrBlank()) FluxaDimensions.CardText.coverFallbackSize else FluxaDimensions.CardText.coverEmojiSize,
fontWeight = FontWeight.Black
)
}
if (loadArtwork && showLogo && logoRequest != null) {
Box(modifier = Modifier.fillMaxSize().background(MOBILE_CARD_BOTTOM_GRADIENT))
AsyncImage(
model = logoRequest,
contentDescription = null,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 9.dp, bottom = 9.dp)
.widthIn(max = width * 0.42f)
.heightIn(max = imageHeight * 0.30f),
contentScale = ContentScale.Fit
)
}
if (isProgressCard && !meta.isUpNextContinueItem() && progress > 0f) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp)
.height(FluxaDimensions.cardProgressBarHeight)
.clip(RoundedCornerShape(999.dp))
.background(Color.Black.copy(alpha = 0.50f))
) {
Box(
modifier = Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(FluxaColors.progressFill)
)
}
}
if (isProgressCard && meta.isUpNextContinueItem()) {
Text(
text = AppStrings.t(lang, "auto.up_next"),
color = Color.White,
fontSize = FluxaDimensions.CardText.subtitleSize,
fontWeight = FontWeight.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.background(Color.Black.copy(alpha = FluxaDimensions.Alpha.upNextBadge))
.padding(horizontal = 8.dp, vertical = 5.dp)
)
}
}
if (!hideTitle) {
if (showTitle) {
Text(
text = meta.name,
color = Color.White,
@ -811,10 +269,9 @@ private fun LeanTopTenPosterCard(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp)
)
val secondary = meta.releaseInfo?.take(4) ?: meta.released?.take(4) ?: ""
if (secondary.isNotBlank()) {
if (secondaryText.isNotBlank()) {
Text(
text = secondary,
text = secondaryText,
color = Color.White.copy(alpha = FluxaDimensions.Alpha.cardSubtitle),
fontSize = FluxaDimensions.CardText.subtitleSize,
fontWeight = FontWeight.Bold,
@ -828,6 +285,12 @@ private fun LeanTopTenPosterCard(
}
}
private val MOBILE_CARD_BOTTOM_GRADIENT = Brush.verticalGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f)),
startY = 150f
)
internal fun homeArtworkMemoryCacheKey(url: String?, width: Int, height: Int): String? {
return url?.takeIf { it.isNotBlank() }?.let { "home-artwork:${width}x$height:$it" }
}

View file

@ -38,12 +38,21 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private fun Modifier.overlayAboveBottom(gap: androidx.compose.ui.unit.Dp): Modifier = layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val gapPx = gap.roundToPx()
layout(0, 0) {
placeable.place(0, -gapPx - placeable.height)
}
}
@Composable
internal fun PlayerTopIconButton(icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
@ -119,9 +128,10 @@ internal fun MobilePlayerSeekbar(
duration: Long,
bufferedFraction: Float,
onSeek: (Long) -> Unit,
accentColor: Color = Color(0xFFE53935),
accentColor: Color = FluxaColors.accent,
onScrubbingChange: (Boolean, Long) -> Unit = { _, _ -> },
seekPreviewBitmap: ImageBitmap? = null
seekPreviewBitmap: ImageBitmap? = null,
chapters: List<com.fluxa.app.player.Chapter> = emptyList()
) {
var sliderPosition by remember(duration) { mutableFloatStateOf(position.toFloat()) }
var isDragging by remember { mutableStateOf(false) }
@ -132,35 +142,72 @@ internal fun MobilePlayerSeekbar(
}
}
val chapterBoundaries = remember(chapters, duration) {
if (chapters.size >= 2 && duration > 0L) {
(chapters.map { it.startMs.toFloat() / duration } + 1f).sorted()
} else {
emptyList()
}
}
val previewChapterTitle = remember(chapters, sliderPosition) {
if (chapters.isEmpty()) null
else chapters.lastOrNull { it.startMs <= sliderPosition }?.title?.takeIf { it.isNotBlank() }
}
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
if (isDragging && seekPreviewBitmap != null) {
if (isDragging && (seekPreviewBitmap != null || previewChapterTitle != null)) {
val fraction = if (duration > 0L) (sliderPosition / duration.toFloat()).coerceIn(0f, 1f) else 0f
val cardWidth = 160.dp
val cardWidth = 200.dp
val rawLeft = maxWidth * fraction - cardWidth / 2
val clampedLeft = rawLeft.coerceIn(0.dp, maxOf(0.dp, maxWidth - cardWidth))
Column(
modifier = Modifier
.offset(x = clampedLeft, y = (-116).dp)
.width(cardWidth)
.clip(RoundedCornerShape(6.dp))
.background(Color.Black.copy(alpha = 0.82f))
.border(1.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(6.dp)),
.overlayAboveBottom(gap = 14.dp)
.offset(x = clampedLeft)
.width(cardWidth),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = seekPreviewBitmap,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f),
contentScale = ContentScale.Crop
)
if (seekPreviewBitmap != null) {
Image(
bitmap = seekPreviewBitmap,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(6.dp))
.border(1.dp, Color.White.copy(alpha = 0.18f), RoundedCornerShape(6.dp)),
contentScale = ContentScale.Crop
)
}
if (previewChapterTitle != null) {
Text(
text = previewChapterTitle,
color = Color.White.copy(alpha = 0.85f),
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = androidx.compose.ui.text.TextStyle(
shadow = androidx.compose.ui.graphics.Shadow(
color = Color.Black.copy(alpha = 0.7f),
blurRadius = 6f
)
),
modifier = Modifier.padding(top = 2.dp)
)
}
Text(
text = formatTime(sliderPosition.toLong()),
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
style = androidx.compose.ui.text.TextStyle(
shadow = androidx.compose.ui.graphics.Shadow(
color = Color.Black.copy(alpha = 0.7f),
blurRadius = 6f
)
),
modifier = Modifier.padding(vertical = 3.dp)
)
}
@ -190,7 +237,7 @@ internal fun MobilePlayerSeekbar(
thumb = {
Box(
modifier = Modifier
.size(18.dp)
.size(12.dp)
.clip(CircleShape)
.background(accentColor)
)
@ -209,21 +256,60 @@ internal fun MobilePlayerSeekbar(
0f
}
val visibleBufferedFraction = bufferedFraction.coerceIn(0f, 1f).coerceAtLeast(activeFraction)
drawRoundRect(
color = Color.Black,
size = androidx.compose.ui.geometry.Size(size.width, trackHeight),
cornerRadius = radius
)
drawRoundRect(
color = Color(0xFFBDBDBD),
size = androidx.compose.ui.geometry.Size(size.width * visibleBufferedFraction, trackHeight),
cornerRadius = radius
)
drawRoundRect(
color = accentColor,
size = androidx.compose.ui.geometry.Size(size.width * activeFraction, trackHeight),
cornerRadius = radius
)
if (chapterBoundaries.isEmpty()) {
drawRoundRect(
color = Color.Black,
size = androidx.compose.ui.geometry.Size(size.width, trackHeight),
cornerRadius = radius
)
drawRoundRect(
color = Color(0xFFBDBDBD),
size = androidx.compose.ui.geometry.Size(size.width * visibleBufferedFraction, trackHeight),
cornerRadius = radius
)
drawRoundRect(
color = accentColor,
size = androidx.compose.ui.geometry.Size(size.width * activeFraction, trackHeight),
cornerRadius = radius
)
} else {
val gapPx = 3.dp.toPx()
val segRadius = androidx.compose.ui.geometry.CornerRadius(trackHeight / 3f)
var start = 0f
for (end in chapterBoundaries) {
val left = size.width * start + gapPx / 2f
val right = (size.width * end - gapPx / 2f).coerceAtLeast(left)
val segWidth = right - left
if (segWidth > 0f) {
drawRoundRect(
color = Color.Black,
topLeft = androidx.compose.ui.geometry.Offset(left, 0f),
size = androidx.compose.ui.geometry.Size(segWidth, trackHeight),
cornerRadius = segRadius
)
val bufferedRight = (size.width * visibleBufferedFraction).coerceIn(left, right)
if (bufferedRight > left) {
drawRoundRect(
color = Color(0xFFBDBDBD),
topLeft = androidx.compose.ui.geometry.Offset(left, 0f),
size = androidx.compose.ui.geometry.Size(bufferedRight - left, trackHeight),
cornerRadius = segRadius
)
}
val activeRight = (size.width * activeFraction).coerceIn(left, right)
if (activeRight > left) {
drawRoundRect(
color = accentColor,
topLeft = androidx.compose.ui.geometry.Offset(left, 0f),
size = androidx.compose.ui.geometry.Size(activeRight - left, trackHeight),
cornerRadius = segRadius
)
}
}
start = end
}
}
}
}
)

View file

@ -5,9 +5,10 @@ import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.domain.discovery.*
import com.fluxa.app.player.AudioCodecBadge
import com.fluxa.app.player.VideoFormatBadge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
@ -35,6 +36,7 @@ internal fun MobilePlayerUIContent(
duration: Long,
position: Long,
bufferedFraction: Float,
chapters: List<com.fluxa.app.player.Chapter> = emptyList(),
isPlaying: Boolean,
isBuffering: Boolean,
hasStartedPlaying: Boolean,
@ -52,6 +54,7 @@ internal fun MobilePlayerUIContent(
hasNextEpisode: Boolean,
showSourcesButton: Boolean,
showEpisodesButton: Boolean,
introDbMarkingEnabled: Boolean = false,
onPlayPrevious: () -> Unit,
onPlayNext: () -> Unit,
onCast: () -> Unit,
@ -63,9 +66,8 @@ internal fun MobilePlayerUIContent(
isScrubbing: Boolean = false,
scrubPosition: Long = 0L,
onScrubbingChange: (Boolean, Long) -> Unit = { _, _ -> },
accentColor: Color = Color(0xFFE53935),
audioCodecBadge: AudioCodecBadge? = null,
videoFormatBadge: VideoFormatBadge? = null
onScrubSeek: (Long) -> Unit = {},
accentColor: Color = FluxaColors.accent
) {
val topFade = Brush.verticalGradient(
listOf(Color.Black.copy(alpha = 0.72f), Color.Transparent)
@ -89,6 +91,10 @@ internal fun MobilePlayerUIContent(
.background(bottomFade)
)
// Interactive content respects safe-area insets; the scrims above stay full-bleed
// so dimming still reaches the true screen edge once zoom removes letterboxing.
Box(modifier = Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
// Top bar
Box(
modifier = Modifier
@ -147,48 +153,47 @@ internal fun MobilePlayerUIContent(
)
}
}
Column(
Row(
modifier = Modifier.align(Alignment.TopEnd),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
PlayerTopIconButton(FluxaIcons.Cast, onCast)
PlayerTopIconButton(FluxaIcons.OpenInNew, onOpenInExternalPlayer)
PlayerTopIconButton(FluxaIcons.PictureInPictureAlt, onPictureInPicture)
}
audioCodecBadge?.let { AudioCodecBadgeView(it) }
videoFormatBadge?.let { VideoFormatBadgeView(it) }
PlayerTopIconButton(FluxaIcons.Cast, onCast)
PlayerTopIconButton(FluxaIcons.OpenInNew, onOpenInExternalPlayer)
PlayerTopIconButton(FluxaIcons.PictureInPictureAlt, onPictureInPicture)
}
}
// Transport controls — centered in the screen
Row(
// Transport controls
AnimatedVisibility(
visible = !isScrubbing,
modifier = Modifier.align(Alignment.Center),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
enter = fadeIn(),
exit = fadeOut()
) {
MobileTransportButton(FluxaIcons.SkipPrevious, enabled = hasPreviousEpisode) { onPlayPrevious() }
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.46f))
.border(1.dp, Color.White.copy(alpha = 0.12f), CircleShape)
.clickable { onPlayPause() },
contentAlignment = Alignment.Center
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (isPlaying) FluxaIcons.Pause else FluxaIcons.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(32.dp)
)
MobileTransportButton(FluxaIcons.SkipPrevious, enabled = hasPreviousEpisode) { onPlayPrevious() }
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.46f))
.border(1.dp, Color.White.copy(alpha = 0.12f), CircleShape)
.clickable { onPlayPause() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isPlaying) FluxaIcons.Pause else FluxaIcons.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
MobileTransportButton(FluxaIcons.SkipNext, enabled = hasNextEpisode) { onPlayNext() }
}
MobileTransportButton(FluxaIcons.SkipNext, enabled = hasNextEpisode) { onPlayNext() }
}
// Seekbar + bottom actions — pinned to bottom
@ -214,15 +219,16 @@ internal fun MobilePlayerUIContent(
.weight(1f)
.padding(horizontal = 10.dp)
) {
val seekPreview = rememberSeekThumbnail(LocalSeekSurfaceView.current, scrubPosition, isScrubbing)
val seekPreview = rememberSeekThumbnail(LocalSeekSurfaceView.current, scrubPosition, isScrubbing, position, isPlaying, onScrubSeek)
MobilePlayerSeekbar(
position = position,
duration = duration,
bufferedFraction = bufferedFraction,
onSeek = onSeek,
accentColor = Color(0xFFE50914),
accentColor = FluxaColors.accent,
onScrubbingChange = onScrubbingChange,
seekPreviewBitmap = seekPreview
seekPreviewBitmap = seekPreview,
chapters = chapters
)
}
Text(
@ -270,7 +276,16 @@ internal fun MobilePlayerUIContent(
modifier = Modifier.weight(1f)
)
}
if (introDbMarkingEnabled) {
MobileBottomAction(
icon = FluxaIcons.BookmarkBorder,
label = playerText(lang, "mark_segment"),
onClick = { onShowSettings(5) },
modifier = Modifier.weight(1f)
)
}
}
}
}
}
}

View file

@ -1,6 +1,7 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import androidx.activity.compose.BackHandler
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
@ -58,24 +59,28 @@ import com.fluxa.app.player.MediaTrack
internal fun MobilePlayerEpisodeRow(
episode: Video,
isSelected: Boolean,
lang: String,
accentColor: Color,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(14.dp))
.background(if (isSelected) Color.White.copy(alpha = 0.08f) else Color.Transparent)
.clickable { onClick() }
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.Top
) {
Box(
modifier = Modifier
.width(110.dp)
.height(64.dp)
.clip(RoundedCornerShape(8.dp))
.width(128.dp)
.height(72.dp)
.clip(RoundedCornerShape(10.dp))
.then(
if (isSelected) Modifier.border(2.dp, accentColor, RoundedCornerShape(10.dp)) else Modifier
)
) {
AsyncImage(
model = episode.thumbnail,
@ -86,31 +91,66 @@ internal fun MobilePlayerEpisodeRow(
if (isSelected) {
Box(
modifier = Modifier
.align(Alignment.Center)
.size(26.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)),
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.35f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = FluxaIcons.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
Box(
modifier = Modifier
.size(30.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = FluxaIcons.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
}
episode.episodeRuntime?.takeIf { it > 0 }?.let { runtime ->
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(4.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.Black.copy(alpha = 0.65f))
.padding(horizontal = 5.dp, vertical = 2.dp)
) {
Text(
text = AppStrings.runtimeMinutes(lang, runtime),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
Text(
text = "${episode.number ?: 0}. ${episode.name.orEmpty()}",
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "${episode.number ?: 0}. ${episode.name.orEmpty()}",
color = if (isSelected) accentColor else Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
episode.overview?.takeIf { it.isNotBlank() }?.let { overview ->
Spacer(Modifier.height(4.dp))
Text(
text = overview,
color = Color.White.copy(alpha = 0.55f),
fontSize = 12.sp,
lineHeight = 16.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
@ -141,7 +181,7 @@ internal fun MobilePlayerChoiceDialog(
.fillMaxWidth(0.84f)
.widthIn(max = 360.dp)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xFF12161D))
.background(FluxaColors.surface)
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
.padding(22.dp)
) {

View file

@ -1,5 +1,6 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
@ -76,7 +77,7 @@ import java.text.SimpleDateFormat
import java.util.Locale
@Composable
fun SourceSelectionScreen(
fun MobileSourceSelectionScreen(
meta: Meta,
video: Video?,
videoId: String?,
@ -106,7 +107,7 @@ fun SourceSelectionScreen(
var subtitleDialog by remember { androidx.compose.runtime.mutableStateOf<Pair<Stream, List<OfflineSubtitleOption>>?>(null) }
var downloadActionStream by remember { androidx.compose.runtime.mutableStateOf<Stream?>(null) }
var loadingDownloadStream by remember { androidx.compose.runtime.mutableStateOf<Stream?>(null) }
val accent = Color(activeProfile?.safeAccentColorArgb ?: 0xFFE50914.toInt())
val accent = Color(activeProfile?.safeAccentColorArgb ?: FluxaColors.accentArgb)
val targetId = video?.id ?: videoId ?: meta.id
val title = detail?.name?.takeIf { it.isNotBlank() } ?: meta.name
var logoLoadFailed by remember(targetId) { androidx.compose.runtime.mutableStateOf(false) }
@ -632,7 +633,7 @@ private fun MobileSourceDownloadActionSheet(
}
@Composable
private fun SourceSubtitleOptionRow(label: String, onClick: () -> Unit) {
internal fun SourceSubtitleOptionRow(label: String, onClick: () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
@ -645,7 +646,7 @@ private fun SourceSubtitleOptionRow(label: String, onClick: () -> Unit) {
}
}
private suspend fun fetchDownloadSubtitleOptions(
internal suspend fun fetchDownloadSubtitleOptions(
viewModel: DetailViewModel,
addons: List<AddonDescriptor>,
profile: UserProfile?,
@ -688,7 +689,7 @@ private suspend fun fetchDownloadSubtitleOptions(
}
}
private suspend fun enqueueOfflineDownload(
internal suspend fun enqueueOfflineDownload(
downloadManager: OfflineDownloadManager,
profile: UserProfile?,
meta: Meta,
@ -714,7 +715,7 @@ private suspend fun enqueueOfflineDownload(
).show()
}
private fun Stream.isOfflineDownloadable(): Boolean {
internal fun Stream.isOfflineDownloadable(): Boolean {
val url = playableUrl.orEmpty()
if (!url.startsWith("http://") && !url.startsWith("https://")) return false
val normalized = url.lowercase(Locale.ROOT)
@ -726,7 +727,7 @@ private fun Stream.isOfflineDownloadable(): Boolean {
return true
}
private fun formatSourceEpisodeDate(value: String?, lang: String): String? {
internal fun formatSourceEpisodeDate(value: String?, lang: String): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.length < 10) return null
val date = trimmed.take(10)

View file

@ -6,6 +6,7 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@ -48,12 +49,8 @@ import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListPrefetchScope
import androidx.compose.foundation.lazy.LazyListPrefetchStrategy
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.layout.NestedPrefetchScope
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.ui.platform.LocalContext
import com.fluxa.app.data.local.LibraryUserCollection
@ -62,12 +59,6 @@ import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.remote.Meta
import kotlinx.coroutines.flow.distinctUntilChanged
private object NoopHomeLazyListPrefetchStrategy : LazyListPrefetchStrategy {
override fun LazyListPrefetchScope.onScroll(delta: Float, layoutInfo: LazyListLayoutInfo) = Unit
override fun LazyListPrefetchScope.onVisibleItemsUpdated(layoutInfo: LazyListLayoutInfo) = Unit
override fun NestedPrefetchScope.onNestedPrefetch(firstVisibleItemIndex: Int) = Unit
}
@Composable
fun HomeScreen(
activeProfile: UserProfile?,
@ -115,8 +106,7 @@ fun HomeScreen(
val stableLoadMoreCategory = remember<(String) -> Unit> { { categoryId -> viewModel.loadMore(categoryId) } }
val homeListState = rememberLazyListState(
initialFirstVisibleItemIndex = viewModel.savedHomeScrollIndex,
initialFirstVisibleItemScrollOffset = viewModel.savedHomeScrollOffset,
prefetchStrategy = NoopHomeLazyListPrefetchStrategy
initialFirstVisibleItemScrollOffset = viewModel.savedHomeScrollOffset
)
DisposableEffect(Unit) {
@ -540,7 +530,7 @@ private fun HomeCategoryRow(
val currentOnPlayDirect = rememberUpdatedState(onPlayDirect)
val currentOnProgressAction = rememberUpdatedState(onProgressAction)
val currentOnLoadMore = rememberUpdatedState(onLoadMore)
val listState = rememberLazyListState(prefetchStrategy = NoopHomeLazyListPrefetchStrategy)
val listState = rememberLazyListState()
val context = LocalContext.current
val addonIconRequest = remember(addonIconUrl) {
addonIconUrl?.takeIf { it.isNotBlank() }?.let {
@ -832,7 +822,7 @@ private fun HomeCatalogCard(
modifier = Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(Color(0xFFE50914))
.background(FluxaColors.progressFill)
)
}
}