Migrate tween literals to AnimDuration tokens across 14 files

Replace all raw tween(N) integer literals with named FluxaDimensions.AnimDuration
constants in player overlays, sidebar, source panel, profile components, parents
guide, login, explore, detail, library, source, search, and route transitions.
This commit is contained in:
KhooLy 2026-07-03 12:19:54 +03:00
parent c9ecb5477a
commit 44683af3c1
14 changed files with 347 additions and 234 deletions

View file

@ -178,7 +178,7 @@ private fun TvQrLoginView(
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF221F1F), Color(0xFF0F0F0F)),
colors = listOf(Color(0xFF221F1F), FluxaColors.backgroundNearBlack),
center = androidx.compose.ui.geometry.Offset(0.5f, 0.5f)
)
),
@ -292,9 +292,9 @@ private fun TvKeyboardAuthForm(
fun continueAsGuest() {
val guest = UserProfile(
id = UUID.randomUUID().toString(),
email = AppStrings.t(lang, "auth.guest_name"),
email = AppStrings.t(lang, "auth.primary_profile_name"),
authKey = "",
isGuest = true
isGuest = false
)
profileManager.saveProfile(guest)
profileManager.setLastActiveProfile(guest)
@ -350,7 +350,7 @@ private fun TvKeyboardAuthForm(
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF221F1F), Color(0xFF0F0F0F)),
colors = listOf(Color(0xFF221F1F), FluxaColors.backgroundNearBlack),
center = androidx.compose.ui.geometry.Offset(0.5f, 0.5f)
)
),
@ -416,7 +416,7 @@ private fun TvKeyboardAuthForm(
errorMessage?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = Color(0xFFFF6B6B))
Text(it, color = FluxaColors.errorRed)
}
Spacer(Modifier.height(20.dp))
@ -462,7 +462,7 @@ private fun TvNuvioLoginView(
.fillMaxSize()
.background(
Brush.radialGradient(
colors = listOf(Color(0xFF221F1F), Color(0xFF0F0F0F)),
colors = listOf(Color(0xFF221F1F), FluxaColors.backgroundNearBlack),
center = androidx.compose.ui.geometry.Offset(0.5f, 0.5f)
)
),
@ -517,7 +517,7 @@ private fun TvNuvioLoginView(
)
errorMessage?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = Color(0xFFFF6B6B))
Text(it, color = FluxaColors.errorRed)
}
Spacer(Modifier.height(20.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
@ -586,7 +586,7 @@ fun PremiumLoadingIndicator(text: String) {
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing)
animation = tween(FluxaDimensions.AnimDuration.loginPulse, easing = LinearEasing)
),
label = ""
)

View file

@ -57,26 +57,26 @@ fun PlayerParentsGuideOverlay(
if (isVisible && !animating) {
animating = true
containerAlpha.animateTo(1f, tween(300))
lineHeightFraction.animateTo(1f, tween(400, easing = FastOutSlowInEasing))
containerAlpha.animateTo(1f, tween(FluxaDimensions.AnimDuration.parentsContainer))
lineHeightFraction.animateTo(1f, tween(FluxaDimensions.AnimDuration.parentsExpand, easing = FastOutSlowInEasing))
for (i in 0 until count) {
delay(80)
itemAlphas[i].animateTo(1f, tween(200))
itemAlphas[i].animateTo(1f, tween(FluxaDimensions.AnimDuration.fadeIn))
}
delay(5000)
for (i in (count - 1) downTo 0) {
delay(60)
itemAlphas[i].animateTo(0f, tween(150))
itemAlphas[i].animateTo(0f, tween(FluxaDimensions.AnimDuration.heroSnap))
}
delay(100)
lineHeightFraction.animateTo(0f, tween(300, easing = FastOutSlowInEasing))
lineHeightFraction.animateTo(0f, tween(FluxaDimensions.AnimDuration.parentsContainer, easing = FastOutSlowInEasing))
delay(200)
containerAlpha.animateTo(0f, tween(200))
containerAlpha.animateTo(0f, tween(FluxaDimensions.AnimDuration.fadeIn))
animating = false
onAnimationComplete()

View file

@ -233,7 +233,7 @@ internal fun SegmentSkipChevronFeedback() {
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(560, easing = FastOutSlowInEasing),
animation = tween(FluxaDimensions.AnimDuration.nextEpisode, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Restart
),
label = "segmentSkipPhase"
@ -301,7 +301,7 @@ internal fun ArtisticLoadingOverlay(bg: String, logo: String, title: String, sta
}
val loadProgress by animateFloatAsState(
targetValue = targetProgress,
animationSpec = tween(520, easing = FastOutSlowInEasing),
animationSpec = tween(FluxaDimensions.AnimDuration.progressRing, easing = FastOutSlowInEasing),
label = "logoLoadProgress"
)
val hasProgress = bufferingMedia || loadProgress > 0.015f || targetProgress > 0.015f
@ -311,7 +311,7 @@ internal fun ArtisticLoadingOverlay(bg: String, logo: String, title: String, sta
initialValue = 0.42f,
targetValue = 0.66f,
animationSpec = infiniteRepeatable(
animation = tween(1120, easing = FastOutSlowInEasing),
animation = tween(FluxaDimensions.AnimDuration.marquee, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "loadingLogoAlpha"

View file

@ -76,8 +76,8 @@ internal fun PlayerSkipSegmentOverlay(
AnimatedVisibility(
visible = activeSkipSegment != null || showNextEpisodeCard,
enter = fadeIn(animationSpec = tween(180)) + slideInVertically(animationSpec = tween(220, easing = FastOutSlowInEasing)) { it / 2 },
exit = fadeOut(animationSpec = tween(140)) + slideOutVertically(animationSpec = tween(180, easing = FastOutSlowInEasing)) { it / 2 },
enter = fadeIn(animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha)) + slideInVertically(animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing)) { it / 2 },
exit = fadeOut(animationSpec = tween(FluxaDimensions.AnimDuration.quick)) + slideOutVertically(animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha, easing = FastOutSlowInEasing)) { it / 2 },
modifier = modifier
.padding(
start = 18.dp,
@ -135,8 +135,8 @@ internal fun BoxScope.PlayerTransientOverlays(
) {
AnimatedVisibility(
visible = showSegmentSkipFeedback,
enter = fadeIn(animationSpec = tween(90)) + scaleIn(animationSpec = tween(140), initialScale = 0.82f),
exit = fadeOut(animationSpec = tween(260)) + scaleOut(animationSpec = tween(260), targetScale = 1.08f),
enter = fadeIn(animationSpec = tween(FluxaDimensions.AnimDuration.blink)) + scaleIn(animationSpec = tween(FluxaDimensions.AnimDuration.quick), initialScale = 0.82f),
exit = fadeOut(animationSpec = tween(FluxaDimensions.AnimDuration.settingsExpandAlt)) + scaleOut(animationSpec = tween(FluxaDimensions.AnimDuration.settingsExpandAlt), targetScale = 1.08f),
modifier = Modifier
.align(Alignment.Center)
.zIndex(320f)
@ -148,8 +148,8 @@ internal fun BoxScope.PlayerTransientOverlays(
AnimatedVisibility(
visible = showZoomOverlay,
enter = fadeIn(animationSpec = tween(100)) + scaleIn(animationSpec = tween(160, easing = FastOutSlowInEasing), initialScale = 0.80f),
exit = fadeOut(animationSpec = tween(220)) + scaleOut(animationSpec = tween(220), targetScale = 1.06f),
enter = fadeIn(animationSpec = tween(FluxaDimensions.AnimDuration.routeExit)) + scaleIn(animationSpec = tween(FluxaDimensions.AnimDuration.routeExit, easing = FastOutSlowInEasing), initialScale = 0.80f),
exit = fadeOut(animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand)) + scaleOut(animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand), targetScale = 1.06f),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = if (deviceType == DeviceType.Mobile) 34.dp else 54.dp)
@ -188,8 +188,8 @@ internal fun BoxScope.PlayerTransientOverlays(
AnimatedVisibility(
visible = holdSpeedVisible,
enter = fadeIn(animationSpec = tween(120)) + scaleIn(animationSpec = tween(180, easing = FastOutSlowInEasing), initialScale = 0.86f),
exit = fadeOut(animationSpec = tween(140)) + scaleOut(animationSpec = tween(140), targetScale = 0.92f),
enter = fadeIn(animationSpec = tween(FluxaDimensions.AnimDuration.heightAnim)) + scaleIn(animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha, easing = FastOutSlowInEasing), initialScale = 0.86f),
exit = fadeOut(animationSpec = tween(FluxaDimensions.AnimDuration.quick)) + scaleOut(animationSpec = tween(FluxaDimensions.AnimDuration.quick), targetScale = 0.92f),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = if (deviceType == DeviceType.Mobile) 34.dp else 54.dp)

View file

@ -80,7 +80,7 @@ fun SeekFeedback(direction: Int, seekMs: Long) {
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(620, easing = FastOutSlowInEasing),
animation = tween(FluxaDimensions.AnimDuration.sidebarSlide, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Restart
), label = "chevronShift"
)

View file

@ -255,8 +255,8 @@ fun PlayerSidebarShell(
) {
var shown by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { shown = true }
val panelAlpha by animateFloatAsState(if (shown) 1f else 0f, animationSpec = tween(180), label = "sidebarAlpha")
val panelOffset by animateFloatAsState(if (shown) 0f else 44f, animationSpec = tween(220, easing = FastOutSlowInEasing), label = "sidebarOffset")
val panelAlpha by animateFloatAsState(if (shown) 1f else 0f, animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha), label = "sidebarAlpha")
val panelOffset by animateFloatAsState(if (shown) 0f else 44f, animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing), label = "sidebarOffset")
val isMobile = deviceType == DeviceType.Mobile
val panelShape = if (isMobile) {
if (compactCenterOnMobile) {
@ -409,7 +409,7 @@ fun TrackItem(
isFocused -> Color.White.copy(alpha = 0.18f)
else -> Color.White.copy(alpha = 0.04f)
},
animationSpec = tween(150),
animationSpec = tween(FluxaDimensions.AnimDuration.heroSnap),
label = "bg"
)
val textColor = if (isSelected) Color.Black else Color.White
@ -423,7 +423,7 @@ fun TrackItem(
isFocused -> 1.01f
else -> 1f
},
animationSpec = tween(170, easing = FastOutSlowInEasing),
animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha, easing = FastOutSlowInEasing),
label = "trackScale"
)
val minHeight = if (subtitle.isNullOrBlank()) {
@ -440,7 +440,7 @@ fun TrackItem(
scaleX = scale
scaleY = scale
}
.animateContentSize(animationSpec = tween(220, easing = FastOutSlowInEasing))
.animateContentSize(animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing))
.clip(RoundedCornerShape(if (resolvedDeviceType == DeviceType.TV) 18.dp else 16.dp))
.background(bgColor)
.clickable { onClick() }

View file

@ -173,7 +173,7 @@ internal fun TvProfileOrb(
onClick: () -> Unit
) {
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (isFocused) 1.04f else 1f, tween(180), label = "profileScale")
val scale by animateFloatAsState(if (isFocused) 1.04f else 1f, tween(FluxaDimensions.AnimDuration.scaleAlpha), label = "profileScale")
Column(
modifier = modifier
@ -263,7 +263,7 @@ internal fun TvAddProfileOrb(
onClick: () -> Unit
) {
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(if (isFocused) 1.04f else 1f, tween(180), label = "addProfileScale")
val scale by animateFloatAsState(if (isFocused) 1.04f else 1f, tween(FluxaDimensions.AnimDuration.scaleAlpha), label = "addProfileScale")
Column(
modifier = modifier

View file

@ -7,6 +7,12 @@ import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.domain.discovery.*
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@ -14,6 +20,7 @@ import androidx.compose.foundation.layout.*
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.compose.foundation.lazy.grid.itemsIndexed as mobileGridItemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@ -83,168 +90,179 @@ fun MobileExploreScreen(
}
}
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxSize().background(Color(0xFF040508)),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 18.dp, bottom = 120.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
Column(
modifier = Modifier.fillMaxSize().background(Color(0xFF040508))
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = AppStrings.t(lang, "nav.discover"),
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = AppStrings.t(lang, "nav.discover"),
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 18.dp, bottom = 12.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(24.dp))
.background(Color.White.copy(alpha = 0.08f))
.border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(24.dp))
.background(Color.White.copy(alpha = 0.08f))
.border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(24.dp))
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(FluxaIcons.Search, null, tint = Color.White.copy(alpha = 0.4f), modifier = Modifier.size(20.dp))
Spacer(Modifier.width(10.dp))
androidx.compose.foundation.text.BasicTextField(
value = searchQuery,
onValueChange = {
searchQuery = it
viewModel.search(it)
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
textStyle = androidx.compose.ui.text.TextStyle(color = Color.White, fontSize = 15.sp),
cursorBrush = androidx.compose.ui.graphics.SolidColor(Color(activeProfile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt())),
modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField ->
if (searchQuery.isEmpty()) {
Text(
text = AppStrings.t(lang, "auto.explore_68dd9e4b"),
color = Color.White.copy(alpha = 0.3f),
fontSize = 15.sp
)
}
innerTextField()
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(FluxaIcons.Search, null, tint = Color.White.copy(alpha = 0.4f), modifier = Modifier.size(20.dp))
Spacer(Modifier.width(10.dp))
androidx.compose.foundation.text.BasicTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {
viewModel.search(searchQuery)
focusManager.clearFocus()
}),
textStyle = androidx.compose.ui.text.TextStyle(color = Color.White, fontSize = 15.sp),
cursorBrush = androidx.compose.ui.graphics.SolidColor(Color(activeProfile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt())),
modifier = Modifier.weight(1f),
decorationBox = { innerTextField ->
if (searchQuery.isEmpty()) {
Text(
text = AppStrings.t(lang, "auto.explore_68dd9e4b"),
color = Color.White.copy(alpha = 0.3f),
fontSize = 15.sp
)
}
innerTextField()
}
)
if (searchQuery.isNotEmpty()) {
Icon(
FluxaIcons.Close,
null,
tint = Color.White.copy(alpha = 0.5f),
modifier = Modifier
.size(18.dp)
.clickable {
searchQuery = ""
viewModel.search("")
}
)
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
val exploreAccent = Color(activeProfile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt())
Box(Modifier.weight(1f)) {
ExploreDropdownFilter(
title = AppStrings.t(lang, "auto.content_type"),
options = exploreTypeOptions(lang),
selected = selectedType,
onSelect = { it?.let(onSelectType) },
showTitle = false,
accentColor = exploreAccent
)
}
Box(Modifier.weight(1f)) {
ExploreDropdownFilter(
title = AppStrings.t(lang, "explore.catalog"),
options = catalogOptions,
selected = selectedCatalog,
onSelect = onSelectCatalog,
showTitle = false,
accentColor = exploreAccent
)
}
Box(Modifier.weight(1f)) {
ExploreDropdownFilter(
title = AppStrings.t(lang, "auto.genre_78cde1de"),
options = genreOptions,
selected = selectedGenre,
onSelect = onSelectGenre,
showTitle = false,
accentColor = exploreAccent
)
}
}
if (!isSearchMode) {
Text(
text = AppStrings.t(lang, "auto.results"),
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(start = 16.dp, top = 24.dp, bottom = 12.dp)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
val exploreAccent = Color(activeProfile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt())
Box(Modifier.weight(1f)) {
ExploreDropdownFilter(
title = AppStrings.t(lang, "auto.content_type"),
options = exploreTypeOptions(lang),
selected = selectedType,
onSelect = { it?.let(onSelectType) },
showTitle = false,
accentColor = exploreAccent
)
} else {
Spacer(Modifier.height(12.dp))
}
Box(Modifier.weight(1f)) {
ExploreDropdownFilter(
title = AppStrings.t(lang, "explore.catalog"),
options = catalogOptions,
selected = selectedCatalog,
onSelect = onSelectCatalog,
showTitle = false,
accentColor = exploreAccent
)
}
Box(Modifier.weight(1f)) {
ExploreDropdownFilter(
title = AppStrings.t(lang, "auto.genre_78cde1de"),
options = genreOptions,
selected = selectedGenre,
onSelect = onSelectGenre,
showTitle = false,
accentColor = exploreAccent
)
}
}
if (!isSearchMode) {
Text(
text = AppStrings.t(lang, "auto.results"),
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Black,
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 12.dp)
)
} else {
Spacer(Modifier.height(12.dp))
}
}
if (!isSearchMode && displayedResults.isEmpty() && isDiscoverLoading) {
item(span = { GridItemSpan(maxLineSpan) }) {
Box(
modifier = Modifier.fillMaxWidth().padding(top = 48.dp),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.CircularProgressIndicator(color = Color.White)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 120.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
if (!isSearchMode && displayedResults.isEmpty() && isDiscoverLoading) {
mobileGridItems(items = List(9) { it }) {
MobileExplorePosterCardSkeleton()
}
}
} else if (isSearchMode && displayedSearchRows.isNotEmpty()) {
displayedSearchRows.forEachIndexed { index, row ->
item(
key = "search-row:${row.title}:$index",
span = { GridItemSpan(maxLineSpan) }
) {
MobileBrowseCategoryRow(
title = row.title,
items = row.items,
onMovieClick = { meta -> onMovieClick(meta, row.sourceAddonTransportUrl, row.sourceAddonCatalogType) },
onSearchHistory = viewModel::addToSearchHistory,
cardLayout = if (activeProfile?.safePosterLandscapeMode == true) "horizontal" else activeProfile?.safeCardLayout ?: "vertical",
activeProfile = activeProfile
} else if (isSearchMode && displayedSearchRows.isNotEmpty()) {
displayedSearchRows.forEachIndexed { index, row ->
item(
key = "search-row:${row.title}:$index",
span = { GridItemSpan(maxLineSpan) }
) {
MobileBrowseCategoryRow(
title = row.title,
items = row.items,
onMovieClick = { meta -> onMovieClick(meta, row.sourceAddonTransportUrl, row.sourceAddonCatalogType) },
onSearchHistory = viewModel::addToSearchHistory,
cardLayout = if (activeProfile?.safePosterLandscapeMode == true) "horizontal" else activeProfile?.safeCardLayout ?: "vertical",
activeProfile = activeProfile
)
}
}
} else if (displayedResults.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SearchEmptyState(
title = if (isSearchMode) {
AppStrings.t(lang, "auto.no_results_found")
} else {
AppStrings.t(lang, "auto.no_results_yet")
},
subtitle = if (isSearchMode) {
AppStrings.t(lang, "auto.try_a_shorter_or_slightly_different_title")
} else {
AppStrings.t(lang, "auto.try_different_filters_to_discover_something__1ba561eb")
},
icon = if (isSearchMode) FluxaIcons.Search else FluxaIcons.Explore
)
}
} else {
mobileGridItemsIndexed(
items = displayedResults,
key = { _, movie -> "${movie.type}:${movie.id}" }
) { _, movie ->
val source = discoverSourceFor(movie, resultSources)
MobileExplorePosterCard(
movie = movie,
onClick = { onMovieClick(movie, source?.transportUrl, source?.type) }
)
}
}
} else if (displayedResults.isEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
SearchEmptyState(
title = if (isSearchMode) {
AppStrings.t(lang, "auto.no_results_found")
} else {
AppStrings.t(lang, "auto.no_results_yet")
},
subtitle = if (isSearchMode) {
AppStrings.t(lang, "auto.try_a_shorter_or_slightly_different_title")
} else {
AppStrings.t(lang, "auto.try_different_filters_to_discover_something__1ba561eb")
}
)
}
} else {
mobileGridItemsIndexed(
items = displayedResults,
key = { _, movie -> "${movie.type}:${movie.id}" }
) { _, movie ->
val source = discoverSourceFor(movie, resultSources)
MobileExplorePosterCard(
movie = movie,
onClick = { onMovieClick(movie, source?.transportUrl, source?.type) }
)
}
}
}
@ -282,3 +300,34 @@ private fun MobileExplorePosterCard(
)
}
}
@Composable
private fun MobileExplorePosterCardSkeleton() {
val transition = rememberInfiniteTransition(label = "explore-skeleton")
val alpha by transition.animateFloat(
initialValue = 0.05f,
targetValue = 0.12f,
animationSpec = infiniteRepeatable(
animation = tween(FluxaDimensions.AnimDuration.ambientColor, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "explore-skeleton-alpha"
)
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f)
.clip(RoundedCornerShape(10.dp))
.background(Color.White.copy(alpha = alpha))
)
Spacer(Modifier.height(6.dp))
Box(
modifier = Modifier
.fillMaxWidth(0.7f)
.height(14.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.White.copy(alpha = alpha))
)
}
}

View file

@ -84,6 +84,7 @@ internal fun MobileDetailScreen(
onPlayPrimary: () -> Unit,
onStreamClick: (Stream) -> Unit,
onPlayEpisode: (Video) -> Unit,
onSimilarClick: (Meta) -> Unit = {},
onDownloadEpisode: (Video?) -> Unit = {},
onDownloadEpisodes: (List<Video>) -> Unit = {},
onMarkEpisodeWatched: (Video, Boolean) -> Unit,
@ -114,7 +115,21 @@ internal fun MobileDetailScreen(
?: initialMeta?.poster
var heroTrailer by remember(detail?.id) { mutableStateOf<DetailTrailer?>(null) }
val listState = rememberLazyListState()
val showStickyBar by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
val heroCollapse by remember {
derivedStateOf {
val hero = listState.layoutInfo.visibleItemsInfo.firstOrNull()
when {
listState.firstVisibleItemIndex > 0 -> 1f
hero == null || hero.size == 0 -> 0f
else -> (listState.firstVisibleItemScrollOffset.toFloat() / hero.size).coerceIn(0f, 1f)
}
}
}
val showStickyBar = heroCollapse > 0.6f
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex > 0 }
.collect { heroGone -> if (heroGone) heroTrailer = null }
}
val scope = rememberCoroutineScope()
var showSeasonSelector by remember(detail?.id) { mutableStateOf(false) }
var showEpisodeSortSelector by remember(detail?.id, selectedSeason) { mutableStateOf(false) }
@ -149,23 +164,48 @@ internal fun MobileDetailScreen(
.filter { !detailIsUpcoming(it.released) }
.sortedWith(compareBy<Video>({ it.season ?: selectedSeason }, { it.number ?: Int.MAX_VALUE }))
}
val horizontalEpisodesState = rememberLazyListState()
var scrolledToResume by remember(detail?.id) { mutableStateOf(false) }
LaunchedEffect(visibleEpisodes, resumeVideoId, activeTab) {
if (scrolledToResume || activeTab != "episodes" || resumeVideoId == null) return@LaunchedEffect
val idx = visibleEpisodes.indexOfFirst { it.id == resumeVideoId }
if (idx < 0) return@LaunchedEffect
scrolledToResume = true
if (idx <= 2) return@LaunchedEffect
if (episodeCardsLayout == "horizontal") {
horizontalEpisodesState.scrollToItem(idx)
} else {
listState.scrollToItem(4 + idx)
}
}
Box(modifier = Modifier.fillMaxSize().background(Color(0xFF050505))) {
Box(modifier = Modifier.fillMaxSize().background(FluxaColors.background)) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 120.dp)
) {
item(key = "detail-hero") {
MobileDetailHero(
heroImage = heroImage,
onBack = onBack,
lang = lang,
activeTrailer = heroTrailer,
onStopTrailer = { heroTrailer = null },
onSelectTrailer = if (trailerOnHero) ({ heroTrailer = it }) else null,
accentColor = accentColor
)
Box(
modifier = Modifier.graphicsLayer {
if (listState.firstVisibleItemIndex == 0) {
val offset = listState.firstVisibleItemScrollOffset.toFloat()
translationY = offset * 0.5f
alpha = 1f - (offset / size.height.coerceAtLeast(1f)) * 0.85f
}
}
) {
MobileDetailHero(
heroImage = heroImage,
onBack = onBack,
lang = lang,
activeTrailer = heroTrailer,
featuredTrailer = if (trailerOnHero) trailers.firstOrNull() else null,
onStopTrailer = { heroTrailer = null },
onSelectTrailer = if (trailerOnHero) ({ heroTrailer = it }) else null,
accentColor = accentColor
)
}
}
item {
@ -189,9 +229,10 @@ internal fun MobileDetailScreen(
}
item {
val tabs = remember(type, trailers.isNotEmpty(), similarItems.isNotEmpty(), lang) {
val tabs = remember(type, hasStreamProviders, streams.isNotEmpty(), trailers.isNotEmpty(), similarItems.isNotEmpty(), lang) {
buildList {
if (type == "series") add("episodes" to AppStrings.t(lang, "auto.episodes"))
if (hasStreamProviders || streams.isNotEmpty()) add("sources" to AppStrings.t(lang, "auto.sources"))
if (trailers.isNotEmpty()) add("trailers" to AppStrings.t(lang, "auto.trailers"))
if (similarItems.isNotEmpty()) add("similar" to AppStrings.t(lang, "auto.similar_titles"))
}
@ -206,12 +247,12 @@ internal fun MobileDetailScreen(
val selected = activeTab == key
val textColor by animateColorAsState(
targetValue = if (selected) Color.White else Color.White.copy(alpha = 0.6f),
animationSpec = tween(180),
animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha),
label = "detailTabText"
)
val indicatorWidth by animateDpAsState(
targetValue = if (selected) 44.dp else 0.dp,
animationSpec = tween(190),
animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha),
label = "detailTabIndicator"
)
Column(
@ -359,16 +400,18 @@ internal fun MobileDetailScreen(
}
}
if (visibleEpisodes.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 28.dp),
contentAlignment = Alignment.Center
) {
if (isLoading) {
CircularProgressIndicator(color = Color.White.copy(alpha = 0.7f))
} else {
if (isLoading) {
mobileItems((0 until 4).toList(), key = { "episode-skeleton-$it" }) {
MobileEpisodeRowSkeleton()
}
} else {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 28.dp),
contentAlignment = Alignment.Center
) {
Text(
text = AppStrings.t(lang, "auto.no_episode_information_found"),
color = Color.White.copy(alpha = 0.58f),
@ -381,6 +424,7 @@ internal fun MobileDetailScreen(
if (episodeCardsLayout == "horizontal" && visibleEpisodes.isNotEmpty()) {
item(key = "episodes-horizontal") {
LazyRow(
state = horizontalEpisodesState,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
@ -509,7 +553,9 @@ internal fun MobileDetailScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
mobileItems(similarItems, key = { "${it.type}:${it.id}" }) { item ->
SimilarContentCard(item.toSimilarItemUiModel(), Color.White) {}
SimilarContentCard(item.toSimilarItemUiModel(), Color.White) {
onSimilarClick(item)
}
}
}
}
@ -556,14 +602,14 @@ internal fun MobileDetailScreen(
AnimatedVisibility(
visible = showStickyBar,
enter = fadeIn(tween(150)) + slideInVertically { -it },
exit = fadeOut(tween(150)) + slideOutVertically { -it },
enter = fadeIn(tween(FluxaDimensions.AnimDuration.heroSnap)) + slideInVertically { -it },
exit = fadeOut(tween(FluxaDimensions.AnimDuration.heroSnap)) + slideOutVertically { -it },
modifier = Modifier.align(Alignment.TopStart).fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF050505).copy(alpha = 0.96f))
.background(FluxaColors.background.copy(alpha = 0.96f))
.statusBarsPadding()
.padding(start = 4.dp, end = 16.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically
@ -575,7 +621,12 @@ internal fun MobileDetailScreen(
.clickable { onBack() },
contentAlignment = Alignment.Center
) {
Icon(FluxaIcons.ArrowBack, null, tint = Color.White, modifier = Modifier.size(22.dp))
Icon(
FluxaIcons.ArrowBack,
contentDescription = AppStrings.t(lang, "common.back"),
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
Spacer(Modifier.width(4.dp))
Box(
@ -615,7 +666,7 @@ internal fun MobileDetailScreen(
) {
Icon(
if (isInWatchlist) FluxaIcons.Check else FluxaIcons.Add,
null,
contentDescription = AppStrings.t(lang, if (isInWatchlist) "auto.in_list" else "auto.my_list"),
tint = if (isInWatchlist) accentColor else Color.White,
modifier = Modifier.size(22.dp)
)

View file

@ -144,7 +144,7 @@ internal fun MobileLibraryDashboard(
}
}
val backgroundColor = if (amoledMode) Color.Black else Color(0xFF05070B)
val cardColor = if (amoledMode) Color(0xFF050505) else Color(0xFF111620).copy(alpha = 0.82f)
val cardColor = if (amoledMode) FluxaColors.backgroundAmoled else FluxaColors.backgroundNavy.copy(alpha = 0.82f)
val plannedChunked = remember(plannedItems) { plannedItems.chunked(3) }
val completedChunked = remember(completedItems) { completedItems.chunked(3) }
val favoritesChunked = remember(favoriteItems) { favoriteItems.chunked(3) }
@ -188,17 +188,17 @@ internal fun MobileLibraryDashboard(
val onAccentColor = if (accent.luminance() > 0.5f) Color.Black else Color.White
val chipBackground by animateColorAsState(
targetValue = if (selected) accent else cardColor,
animationSpec = tween(180, easing = FastOutSlowInEasing),
animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha, easing = FastOutSlowInEasing),
label = "libraryChipBackground"
)
val chipBorder by animateColorAsState(
targetValue = if (selected) accent else Color.White.copy(0.1f),
animationSpec = tween(180, easing = FastOutSlowInEasing),
animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha, easing = FastOutSlowInEasing),
label = "libraryChipBorder"
)
val chipScale by animateFloatAsState(
targetValue = if (selected) 1.04f else 1f,
animationSpec = tween(180, easing = FastOutSlowInEasing),
animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha, easing = FastOutSlowInEasing),
label = "libraryChipScale"
)
Box(

View file

@ -425,7 +425,7 @@ internal fun MobileTrackPickerOverlay(
Crossfade(
targetState = settingsMode,
animationSpec = tween(180),
animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha),
modifier = Modifier
.fillMaxWidth()
.weight(1f),

View file

@ -289,7 +289,7 @@ fun MobileBrowseCategoryRow(
cardLayout: String,
activeProfile: UserProfile? = null
) {
Column(modifier = modifier.animateContentSize(animationSpec = tween(220))) {
Column(modifier = modifier.animateContentSize(animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand))) {
SearchSectionTitle(title)
androidx.compose.foundation.lazy.LazyRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),

View file

@ -414,8 +414,8 @@ fun MobileSourceSelectionScreen(
}
AnimatedVisibility(
visible = downloadActionStream != null,
enter = fadeIn(tween(140)) + slideInVertically(tween(220, easing = FastOutSlowInEasing)) { it / 4 },
exit = fadeOut(tween(120)) + slideOutVertically(tween(180, easing = FastOutSlowInEasing)) { it / 4 }
enter = fadeIn(tween(FluxaDimensions.AnimDuration.quick)) + slideInVertically(tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing)) { it / 4 },
exit = fadeOut(tween(FluxaDimensions.AnimDuration.heightAnim)) + slideOutVertically(tween(FluxaDimensions.AnimDuration.scaleAlpha, easing = FastOutSlowInEasing)) { it / 4 }
) {
downloadActionStream?.let { stream ->
MobileSourceDownloadActionSheet(
@ -482,12 +482,12 @@ private fun SourceChip(
) {
val background by animateColorAsState(
targetValue = if (selected) accent else Color.White.copy(alpha = 0.08f),
animationSpec = tween(180, easing = FastOutSlowInEasing),
animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha, easing = FastOutSlowInEasing),
label = "sourceChipBackground"
)
val scale by animateFloatAsState(
targetValue = if (selected) 1.04f else 1f,
animationSpec = tween(180, easing = FastOutSlowInEasing),
animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha, easing = FastOutSlowInEasing),
label = "sourceChipScale"
)
Box(
@ -526,7 +526,7 @@ private fun SourcePageStreamRow(
val rawTitle = stream.streamRawBody()
val rowAlpha by animateFloatAsState(
targetValue = if (isLoading) 0.72f else 1f,
animationSpec = tween(180),
animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha),
label = "sourceRowAlpha"
)
Column(
@ -534,7 +534,7 @@ private fun SourcePageStreamRow(
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 5.dp)
.graphicsLayer { alpha = rowAlpha }
.animateContentSize(animationSpec = tween(220, easing = FastOutSlowInEasing))
.animateContentSize(animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing))
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF2B2A34))
.border(1.dp, Color.White.copy(alpha = 0.05f), RoundedCornerShape(8.dp))
@ -586,7 +586,7 @@ private fun MobileSourceDownloadActionSheet(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp))
.background(Color(0xFF171717))
.background(FluxaColors.surfaceDark)
.pointerInput(stream) {
detectTapGestures(onTap = {})
}

View file

@ -48,8 +48,10 @@ import com.fluxa.app.ui.catalog.ProfileEditScreen
import com.fluxa.app.ui.catalog.ProfileScreen
import com.fluxa.app.ui.catalog.SearchScreen
import com.fluxa.app.ui.catalog.UpdateManager
import com.fluxa.app.ui.catalog.tvNavDestination
import com.fluxa.app.ui.catalog.WatchlistScreen
import com.fluxa.app.ui.catalog.WelcomeScreen
import com.fluxa.app.ui.catalog.FluxaDimensions
import kotlinx.coroutines.CoroutineScope
@Composable
@ -75,6 +77,13 @@ internal fun AppRoutesHost(
onUpdateInfoChanged: (UpdateManager.UpdateInfo?) -> Unit,
navigateBackSafely: () -> Unit
) {
val tvNavActions = com.fluxa.app.ui.catalog.TvNavActions(
onHome = { if (currentScreen.tvNavDestination() != com.fluxa.app.ui.catalog.TvNavDestination.Home) navigator.navigateTo(Screen.Home, clearStack = true) },
onSearch = { if (currentScreen.tvNavDestination() != com.fluxa.app.ui.catalog.TvNavDestination.Search) navigator.navigateTo(Screen.Search, clearStack = true) },
onWatchlist = { if (currentScreen.tvNavDestination() != com.fluxa.app.ui.catalog.TvNavDestination.Library) navigator.navigateTo(Screen.Watchlist, clearStack = true) },
onExplore = { if (currentScreen.tvNavDestination() != com.fluxa.app.ui.catalog.TvNavDestination.Discover) navigator.navigateTo(Screen.Explore(), clearStack = true) },
onSettings = { if (currentScreen.tvNavDestination() != com.fluxa.app.ui.catalog.TvNavDestination.Settings) navigator.navigateTo(Screen.Settings(), clearStack = true) }
)
AnimatedContent(
targetState = currentScreen,
modifier = Modifier.then(
@ -93,23 +102,23 @@ internal fun AppRoutesHost(
val direction = navDirection(initialState, targetState)
val distance = if (deviceType == DeviceType.Mobile) 92 else 54
(
fadeIn(animationSpec = tween(220, easing = FastOutSlowInEasing)) +
fadeIn(animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing)) +
slideInHorizontally(
animationSpec = tween(260, easing = FastOutSlowInEasing),
animationSpec = tween(FluxaDimensions.AnimDuration.settingsExpandAlt, easing = FastOutSlowInEasing),
initialOffsetX = { direction * distance }
) +
scaleIn(
animationSpec = tween(260, easing = FastOutSlowInEasing),
animationSpec = tween(FluxaDimensions.AnimDuration.settingsExpandAlt, easing = FastOutSlowInEasing),
initialScale = 0.985f
)
) togetherWith (
fadeOut(animationSpec = tween(160, easing = LinearOutSlowInEasing)) +
fadeOut(animationSpec = tween(FluxaDimensions.AnimDuration.routeExit, easing = LinearOutSlowInEasing)) +
slideOutHorizontally(
animationSpec = tween(220, easing = FastOutSlowInEasing),
animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing),
targetOffsetX = { -direction * distance }
) +
scaleOut(
animationSpec = tween(220, easing = FastOutSlowInEasing),
animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing),
targetScale = 0.992f
)
)
@ -155,9 +164,9 @@ internal fun AppRoutesHost(
onContinueWithoutAccount = {
val guest = UserProfile(
id = java.util.UUID.randomUUID().toString(),
email = AppStrings.t("en", "auth.guest_name"),
email = AppStrings.t("en", "auth.primary_profile_name"),
authKey = "",
isGuest = true
isGuest = false
)
profileManager.saveProfile(guest)
profileManager.setLastActiveProfile(guest)
@ -191,7 +200,8 @@ internal fun AppRoutesHost(
navigateBackSafely,
homeViewModel,
initialType = screen.initialType,
initialGenre = screen.initialGenre
initialGenre = screen.initialGenre,
tvNavActions = tvNavActions
)
is Screen.Search -> SearchScreen(
activeProfile,
@ -201,7 +211,8 @@ internal fun AppRoutesHost(
navigator.navigateTo(meta.detailScreen(sourceAddonTransportUrl, sourceAddonCatalogType))
},
navigateBackSafely,
homeViewModel
homeViewModel,
tvNavActions
)
is Screen.Calendar -> CalendarScreen(
activeProfile = activeProfile,
@ -248,7 +259,8 @@ internal fun AppRoutesHost(
onActiveProfileChanged(it)
profileManager.saveProfile(it)
homeViewModel.applyUpdatedProfile(it)
}
},
tvNavActions = tvNavActions
)
is Screen.Detail -> DetailRoute(screen, activeProfile, navigator, navigateBackSafely)
is Screen.Sources -> SourcesRoute(screen, activeProfile, navigator, navigateBackSafely)
@ -293,7 +305,8 @@ internal fun AppRoutesHost(
onShowSimklSheet = onShowSimklSheet,
onTraktDeviceAuthChanged = onTraktDeviceAuthChanged,
onPendingMalCodeVerifierChanged = onPendingMalCodeVerifierChanged,
onUpdateInfoChanged = onUpdateInfoChanged
onUpdateInfoChanged = onUpdateInfoChanged,
tvNavActions = tvNavActions
)
}
}