mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-20 14:06:50 +00:00
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:
parent
9384e1257b
commit
663c1154f1
18 changed files with 3577 additions and 830 deletions
|
|
@ -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(
|
||||
|
|
|
|||
725
app/src/main/java/com/fluxa/app/ui/catalog/DetailComponents.kt
Normal file
725
app/src/main/java/com/fluxa/app/ui/catalog/DetailComponents.kt
Normal 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)
|
||||
}
|
||||
16
app/src/main/java/com/fluxa/app/ui/catalog/FluxaColors.kt
Normal file
16
app/src/main/java/com/fluxa/app/ui/catalog/FluxaColors.kt
Normal 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)
|
||||
}
|
||||
510
app/src/main/java/com/fluxa/app/ui/catalog/PlayerOverlays.kt
Normal file
510
app/src/main/java/com/fluxa/app/ui/catalog/PlayerOverlays.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
466
app/src/main/java/com/fluxa/app/ui/catalog/TvMovieCard.kt
Normal file
466
app/src/main/java/com/fluxa/app/ui/catalog/TvMovieCard.kt
Normal 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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
419
app/src/main/java/com/fluxa/app/ui/catalog/TvPlayerControls.kt
Normal file
419
app/src/main/java/com/fluxa/app/ui/catalog/TvPlayerControls.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue