mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-18 04:55:58 +00:00
Add Archivo variable display face and card type hierarchy
Bundle Archivo (OFL) as the brand display family with width/weight axes; displays, titles, shelf row titles, and Top Ten numerals now render in expanded Archivo. Card titles drop from Black/Bold to SemiBold with Medium metadata so shelves get visible hierarchy.
This commit is contained in:
parent
663c1154f1
commit
0e6835be31
8 changed files with 719 additions and 7 deletions
93
app/src/main/assets/fonts/OFL-Archivo.txt
Normal file
93
app/src/main/assets/fonts/OFL-Archivo.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
@file:OptIn(androidx.compose.ui.text.ExperimentalTextApi::class)
|
||||
|
||||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.data.local.*
|
||||
|
|
@ -12,10 +14,21 @@ import androidx.compose.material3.darkColorScheme
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.fluxa.app.R
|
||||
|
||||
val FluxaDisplay = FontFamily(
|
||||
Font(R.font.archivo, weight = FontWeight.Medium, variationSettings = FontVariation.Settings(FontVariation.weight(500), FontVariation.width(106f))),
|
||||
Font(R.font.archivo, weight = FontWeight.SemiBold, variationSettings = FontVariation.Settings(FontVariation.weight(600), FontVariation.width(112f))),
|
||||
Font(R.font.archivo, weight = FontWeight.Bold, variationSettings = FontVariation.Settings(FontVariation.weight(700), FontVariation.width(116f))),
|
||||
Font(R.font.archivo, weight = FontWeight.ExtraBold, variationSettings = FontVariation.Settings(FontVariation.weight(800), FontVariation.width(120f))),
|
||||
Font(R.font.archivo, weight = FontWeight.Black, variationSettings = FontVariation.Settings(FontVariation.weight(900), FontVariation.width(125f)))
|
||||
)
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = FluxaColors.accent,
|
||||
|
|
@ -33,27 +46,27 @@ private val DarkColorScheme = darkColorScheme(
|
|||
|
||||
private val AppTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontFamily = FluxaDisplay,
|
||||
fontWeight = FontWeight.Black,
|
||||
fontSize = 52.sp,
|
||||
lineHeight = 56.sp,
|
||||
letterSpacing = (-1.5).sp
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontFamily = FluxaDisplay,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 40.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = (-1).sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontFamily = FluxaDisplay,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontFamily = FluxaDisplay,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
@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 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.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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.Shadow
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
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.PlatformTextStyle
|
||||
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.TextUnit
|
||||
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.palette.graphics.Palette
|
||||
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.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.size.Size
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.fluxa.app.R
|
||||
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
|
||||
fun TopTenRankedPosterCard(
|
||||
rank: Int,
|
||||
posterWidth: Dp,
|
||||
posterHeight: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
posterCornerRadius: Dp = 12.dp,
|
||||
isFocused: Boolean = false,
|
||||
posterContent: @Composable BoxScope.() -> Unit
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
|
||||
val numberBoxWidth = when {
|
||||
rank >= 10 -> posterWidth * 1.08f
|
||||
rank == 1 -> posterWidth * 0.54f
|
||||
else -> posterWidth * 0.82f
|
||||
}
|
||||
|
||||
val posterOverlap = when {
|
||||
rank >= 10 -> posterWidth * 0.33f
|
||||
rank == 1 -> posterWidth * 0.13f
|
||||
else -> posterWidth * 0.24f
|
||||
}
|
||||
|
||||
val fontSize = remember(posterHeight, density) {
|
||||
with(density) { (posterHeight.toPx() * 0.90f).toSp() }
|
||||
}
|
||||
|
||||
val totalWidth = numberBoxWidth + posterWidth - posterOverlap
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(totalWidth)
|
||||
.height(posterHeight),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.width(numberBoxWidth)
|
||||
.fillMaxHeight()
|
||||
.zIndex(0f),
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
TopTenRankNumber(
|
||||
rank = rank,
|
||||
fontSize = fontSize,
|
||||
modifier = Modifier.offset(
|
||||
x = when {
|
||||
rank == 1 -> 8.dp
|
||||
rank >= 10 -> 0.dp
|
||||
else -> 3.dp
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.width(posterWidth)
|
||||
.height(posterHeight)
|
||||
.clip(RoundedCornerShape(posterCornerRadius))
|
||||
.zIndex(1f)
|
||||
.then(
|
||||
if (isFocused) {
|
||||
Modifier.border(
|
||||
width = 2.dp,
|
||||
color = Color.White.copy(alpha = 0.86f),
|
||||
shape = RoundedCornerShape(posterCornerRadius)
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
posterContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopTenRankNumber(
|
||||
rank: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
fontSize: TextUnit = 178.sp
|
||||
) {
|
||||
val deviceType = LocalDeviceType.current
|
||||
if (deviceType == DeviceType.Mobile) {
|
||||
Text(
|
||||
text = rank.toString(),
|
||||
color = Color(0xFF111111),
|
||||
fontSize = fontSize,
|
||||
lineHeight = fontSize,
|
||||
fontFamily = FluxaDisplay,
|
||||
fontWeight = FontWeight.Black,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = modifier,
|
||||
style = TextStyle(
|
||||
platformStyle = PlatformTextStyle(includeFontPadding = false)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Box(modifier = modifier) {
|
||||
Text(
|
||||
text = rank.toString(),
|
||||
color = Color.White.copy(alpha = 0.86f),
|
||||
fontSize = fontSize,
|
||||
lineHeight = fontSize,
|
||||
fontFamily = FluxaDisplay,
|
||||
fontWeight = FontWeight.Black,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
style = TextStyle(
|
||||
drawStyle = Stroke(width = 6f),
|
||||
shadow = Shadow(
|
||||
color = Color.Black.copy(alpha = 0.65f),
|
||||
offset = Offset(0f, 4f),
|
||||
blurRadius = 8f
|
||||
),
|
||||
platformStyle = PlatformTextStyle(includeFontPadding = false)
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = rank.toString(),
|
||||
color = Color(0xFF0B0B0B),
|
||||
fontSize = fontSize,
|
||||
lineHeight = fontSize,
|
||||
fontFamily = FluxaDisplay,
|
||||
fontWeight = FontWeight.Black,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
style = TextStyle(
|
||||
platformStyle = PlatformTextStyle(includeFontPadding = false)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,385 @@
|
|||
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class)
|
||||
|
||||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.common.AppStrings
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
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.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import coil3.imageLoader
|
||||
import coil3.request.ImageRequest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.tv.material3.Text
|
||||
import coil3.compose.AsyncImage
|
||||
import com.fluxa.app.data.local.UserProfile
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
|
||||
@Composable
|
||||
internal fun ContinueWatchingHighlight(
|
||||
movie: Meta,
|
||||
lang: String,
|
||||
label: String,
|
||||
focusRequester: FocusRequester? = null,
|
||||
upFocusRequester: FocusRequester? = null,
|
||||
onClick: () -> Unit,
|
||||
onFocus: () -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.padding(start = 42.dp, top = 12.dp, bottom = 8.dp)) {
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
fontSize = 30.sp,
|
||||
fontFamily = FluxaDisplay,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
WideContinueCard(
|
||||
movie = movie,
|
||||
lang = lang,
|
||||
focusRequester = focusRequester,
|
||||
upFocusRequester = upFocusRequester,
|
||||
onClick = onClick,
|
||||
onFocus = onFocus
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WideContinueCard(
|
||||
movie: Meta,
|
||||
lang: String,
|
||||
focusRequester: FocusRequester? = null,
|
||||
upFocusRequester: FocusRequester? = null,
|
||||
onClick: () -> Unit,
|
||||
onFocus: () -> Unit
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (isFocused) 1.035f else 1f, tween(180), label = "continueScale")
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.width(360.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(Color.White.copy(alpha = 0.07f))
|
||||
.border(
|
||||
width = if (isFocused) 2.dp else 1.dp,
|
||||
color = if (isFocused) Color(0xFF9AD4FF) else Color.White.copy(alpha = 0.08f),
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
)
|
||||
.scale(scale)
|
||||
.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
|
||||
}
|
||||
)
|
||||
.onFocusChanged {
|
||||
isFocused = it.isFocused
|
||||
if (it.isFocused) onFocus()
|
||||
}
|
||||
.clickable { onClick() }
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AsyncImage(
|
||||
model = movie.continueWatchingBackground,
|
||||
contentDescription = movie.name,
|
||||
modifier = Modifier
|
||||
.width(156.dp)
|
||||
.height(88.dp)
|
||||
.clip(RoundedCornerShape(14.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
movie.lastEpisodeName?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = Color.White.copy(alpha = 0.72f),
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
}
|
||||
Text(
|
||||
text = movie.name,
|
||||
color = Color.White,
|
||||
fontSize = 23.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
val remaining = movie.remainingWatchLabel(lang)
|
||||
if (remaining != null) {
|
||||
HeroBadge(text = remaining, background = Color(0xFF4A6AFB), content = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HomeShelfRow(
|
||||
title: String,
|
||||
items: List<Meta>,
|
||||
cardLayout: String,
|
||||
artworkPreference: String?,
|
||||
activeProfile: UserProfile?,
|
||||
topTenEnabled: Boolean,
|
||||
onItemClick: (Meta) -> Unit,
|
||||
onItemFocus: (Meta) -> Unit,
|
||||
firstItemFocusRequester: FocusRequester? = null,
|
||||
upFocusRequester: FocusRequester? = null,
|
||||
onNavigateUp: (() -> Unit)? = null,
|
||||
onNeedMore: () -> Unit,
|
||||
onResolveTrailer: (suspend (Meta) -> String?)? = null,
|
||||
titleStartPadding: Dp = 42.dp
|
||||
) {
|
||||
var expandedMeta by remember { mutableStateOf<Meta?>(null) }
|
||||
var rowCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
|
||||
var expandedOffsetX by remember { mutableStateOf(42.dp) }
|
||||
var rowWidth by remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
val instantExpand = cardLayout != "horizontal" && cardLayout != "episode" &&
|
||||
activeProfile?.safeExpandedPostersEnabled == true &&
|
||||
(activeProfile.safeExpandedPostersDelaySeconds) == 0
|
||||
val expandedPostersEnabled = cardLayout != "horizontal" && cardLayout != "episode" &&
|
||||
activeProfile?.safeExpandedPostersEnabled == true
|
||||
val lazyListState = rememberLazyListState()
|
||||
val internalFirstCardFocusRequester = remember { FocusRequester() }
|
||||
val firstCardFocusRequester = firstItemFocusRequester ?: internalFirstCardFocusRequester
|
||||
var focusedIndex by remember { mutableStateOf(-1) }
|
||||
LaunchedEffect(focusedIndex, instantExpand) {
|
||||
if (focusedIndex < 0) return@LaunchedEffect
|
||||
snapshotFlow { expandedMeta }.first { it == null }
|
||||
if (instantExpand) {
|
||||
lazyListState.scrollToItem(focusedIndex, 0)
|
||||
} else {
|
||||
lazyListState.animateScrollToItem(focusedIndex, 0)
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(focusedIndex, expandedPostersEnabled) {
|
||||
if (!expandedPostersEnabled || focusedIndex < 0) return@LaunchedEffect
|
||||
listOf(focusedIndex - 1, focusedIndex + 1, focusedIndex + 2).forEach { neighborIndex ->
|
||||
val neighbor = items.getOrNull(neighborIndex) ?: return@forEach
|
||||
val artwork = preferredHorizontalArtwork(neighbor) ?: neighbor.poster
|
||||
if (!artwork.isNullOrBlank()) {
|
||||
launch {
|
||||
context.imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(artwork)
|
||||
.memoryCacheKey(artwork)
|
||||
.diskCacheKey(artwork)
|
||||
.size(640, 360)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(top = 18.dp)
|
||||
.then(
|
||||
if (onNavigateUp != null) Modifier.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionUp) {
|
||||
onNavigateUp()
|
||||
true
|
||||
} else false
|
||||
} else Modifier
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color.White,
|
||||
fontSize = 22.sp,
|
||||
fontFamily = FluxaDisplay,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.height(32.dp)
|
||||
.padding(start = titleStartPadding)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
val rowHeight = when (cardLayout) {
|
||||
"episode" -> 226.dp
|
||||
"horizontal" -> horizontalCardHeight(activeProfile?.safePosterWidthPreset ?: "medium", DeviceType.TV) + 24.dp
|
||||
else -> posterCardHeight(activeProfile?.safePosterWidthPreset ?: "medium") + 82.dp
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxWidth().height(rowHeight)) {
|
||||
val reentryIndex = focusedIndex.coerceAtLeast(0)
|
||||
LazyRow(
|
||||
state = lazyListState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { coordinates ->
|
||||
rowCoordinates = coordinates
|
||||
rowWidth = with(density) { coordinates.size.width.toDp() }
|
||||
},
|
||||
contentPadding = PaddingValues(start = titleStartPadding, end = 50.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
itemsIndexed(items, key = { _, movie -> "${movie.type}:${movie.id}" }) { index, movie ->
|
||||
if (index >= items.lastIndex - 4) {
|
||||
LaunchedEffect(title, index) { onNeedMore() }
|
||||
}
|
||||
TvMovieCard(
|
||||
meta = movie,
|
||||
onFocus = {
|
||||
onItemFocus(movie)
|
||||
focusedIndex = index
|
||||
},
|
||||
onClick = { onItemClick(movie) },
|
||||
cardLayout = cardLayout,
|
||||
artworkPreference = artworkPreference,
|
||||
profile = activeProfile,
|
||||
topTenRank = if (topTenEnabled && index < 10) index + 1 else null,
|
||||
isShelfStyle = true,
|
||||
focusRequester = if (index == reentryIndex) firstCardFocusRequester else null,
|
||||
upFocusRequester = if (index == 0) upFocusRequester else null,
|
||||
onExpandedChange = { isExpanded ->
|
||||
expandedMeta = when {
|
||||
isExpanded -> movie
|
||||
expandedMeta?.id == movie.id && expandedMeta?.type == movie.type -> null
|
||||
else -> expandedMeta
|
||||
}
|
||||
},
|
||||
onExpandedPositioned = { coordinates ->
|
||||
rowCoordinates?.let { rc ->
|
||||
val xPx = rc.localPositionOf(coordinates, Offset.Zero).x
|
||||
expandedOffsetX = with(density) { xPx.toDp() }
|
||||
}
|
||||
},
|
||||
onFocusedPositioned = {},
|
||||
onFocusChanged = { focused ->
|
||||
},
|
||||
onResolveTrailer = onResolveTrailer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
ShelfExpandedDescription(expandedMeta, expandedOffsetX, rowWidth)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ShelfExpandedDescription(expandedMeta: Meta?, expandedOffsetX: Dp, rowWidth: Dp) {
|
||||
val animatedOffsetX by animateDpAsState(expandedOffsetX, tween(220), label = "expandedInfoOffset")
|
||||
Box(modifier = Modifier.fillMaxWidth().height(90.dp)) {
|
||||
AnimatedVisibility(
|
||||
visible = expandedMeta != null,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(150))
|
||||
) {
|
||||
val info = expandedMeta
|
||||
if (info != null) {
|
||||
val metaLine = listOfNotNull(
|
||||
info.releaseInfo?.takeIf { it.isNotBlank() },
|
||||
info.genres?.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(" • "),
|
||||
info.ageRating?.takeIf { it.isNotBlank() }
|
||||
).joinToString(" • ")
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.offset(x = animatedOffsetX)
|
||||
.width((rowWidth - animatedOffsetX - 50.dp).coerceAtLeast(200.dp))
|
||||
.padding(top = 2.dp, bottom = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
if (metaLine.isNotBlank()) {
|
||||
Text(metaLine, color = Color.White.copy(alpha = 0.7f), fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
info.description?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(it, color = Color.White.copy(alpha = 0.78f), fontSize = 13.sp, lineHeight = 18.sp, maxLines = 3, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Meta.remainingWatchLabel(lang: String? = null): String? {
|
||||
val total = duration ?: return null
|
||||
val watched = timeOffset ?: return null
|
||||
val remainingMs = (total - watched).coerceAtLeast(0L)
|
||||
val mins = (remainingMs / 60000L).toInt()
|
||||
if (mins <= 0) return null
|
||||
return if (mins >= 60) {
|
||||
val hours = mins / 60
|
||||
val rest = mins % 60
|
||||
if (rest == 0) {
|
||||
AppStrings.format(lang, "format.remaining_hours", hours)
|
||||
} else {
|
||||
AppStrings.format(lang, "format.remaining_hours_minutes", hours, rest)
|
||||
}
|
||||
} else {
|
||||
AppStrings.format(lang, "format.remaining_minutes", mins)
|
||||
}
|
||||
}
|
||||
|
|
@ -430,7 +430,7 @@ internal fun TvMovieCard(
|
|||
text = meta.name,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ internal fun MobileMovieCard(
|
|||
text = meta.name,
|
||||
color = Color.White,
|
||||
fontSize = FluxaDimensions.CardText.titleSize,
|
||||
fontWeight = FontWeight.Black,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.dp)
|
||||
|
|
@ -274,7 +274,7 @@ internal fun MobileMovieCard(
|
|||
text = secondaryText,
|
||||
color = Color.White.copy(alpha = FluxaDimensions.Alpha.cardSubtitle),
|
||||
fontSize = FluxaDimensions.CardText.subtitleSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 1.dp)
|
||||
|
|
|
|||
BIN
app/src/main/res/font/archivo.ttf
Normal file
BIN
app/src/main/res/font/archivo.ttf
Normal file
Binary file not shown.
|
|
@ -489,6 +489,7 @@ private fun HomeCollectionHeader(
|
|||
) {
|
||||
androidx.compose.material3.Text(
|
||||
text = title,
|
||||
fontFamily = FluxaDisplay,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
|
|
@ -709,6 +710,7 @@ private fun HomeRowHeader(
|
|||
}
|
||||
androidx.compose.material3.Text(
|
||||
text = fullTitle,
|
||||
fontFamily = FluxaDisplay,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
|
|
|
|||
Loading…
Reference in a new issue