mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-31 08:39:24 +00:00
feat: adding tv style cw card
This commit is contained in:
parent
32f99fc252
commit
e89d7c5406
6 changed files with 465 additions and 26 deletions
|
|
@ -586,6 +586,8 @@
|
|||
<string name="settings_continue_watching_section_visibility">VISIBILITY</string>
|
||||
<string name="settings_continue_watching_show_description">Display the Continue Watching shelf on the Home screen.</string>
|
||||
<string name="settings_continue_watching_show_title">Show Continue Watching</string>
|
||||
<string name="settings_continue_watching_style_card">Card</string>
|
||||
<string name="settings_continue_watching_style_card_description">TV-style landscape card</string>
|
||||
<string name="settings_continue_watching_style_poster">Poster</string>
|
||||
<string name="settings_continue_watching_style_poster_description">Artwork-first poster card</string>
|
||||
<string name="settings_continue_watching_style_wide">Wide</string>
|
||||
|
|
@ -1107,6 +1109,8 @@
|
|||
<string name="episode_mark_watched">Mark as watched</string>
|
||||
<string name="home_continue_watching_up_next">Up next</string>
|
||||
<string name="home_continue_watching_watched">%1$s watched</string>
|
||||
<string name="home_continue_watching_hours_minutes_left">%1$dh %2$dm left</string>
|
||||
<string name="home_continue_watching_minutes_left">%1$dm left</string>
|
||||
<string name="home_empty_no_active_addons_message">Install and validate at least one addon before loading catalog rows on Home.</string>
|
||||
<string name="home_empty_no_rows_message">Installed addons do not currently expose board-compatible catalogs without required extras.</string>
|
||||
<string name="home_empty_no_rows_title">No home rows available</string>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
|||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.enabledAddons
|
||||
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||
|
|
@ -79,6 +80,7 @@ import kotlinx.coroutines.flow.emptyFlow
|
|||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import com.nuvio.app.features.home.components.ContinueWatchingLayout
|
||||
import com.nuvio.app.features.home.components.continueWatchingLandscapeCardHeight
|
||||
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
|
||||
import com.nuvio.app.features.home.components.rememberContinueWatchingLayout
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
|
@ -585,6 +587,10 @@ fun HomeScreen(
|
|||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
|
||||
val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value)
|
||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||
val continueWatchingCardHeight = remember(posterCardStyle.widthDp) {
|
||||
continueWatchingLandscapeCardHeight(posterCardStyle.widthDp)
|
||||
}
|
||||
val nativeBottomNavigationOverlayHeight =
|
||||
if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) {
|
||||
nuvioSafeBottomPadding()
|
||||
|
|
@ -597,6 +603,7 @@ fun HomeScreen(
|
|||
continueWatchingPreferences.style,
|
||||
continueWatchingItems.isNotEmpty(),
|
||||
continueWatchingLayout,
|
||||
continueWatchingCardHeight,
|
||||
nativeBottomNavigationOverlayHeight,
|
||||
) {
|
||||
heroMobileBelowSectionHeightHint(
|
||||
|
|
@ -605,6 +612,7 @@ fun HomeScreen(
|
|||
hasContinueWatchingItems = continueWatchingItems.isNotEmpty(),
|
||||
continueWatchingStyle = continueWatchingPreferences.style,
|
||||
continueWatchingLayout = continueWatchingLayout,
|
||||
continueWatchingCardHeight = continueWatchingCardHeight,
|
||||
bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight,
|
||||
)
|
||||
}
|
||||
|
|
@ -967,11 +975,13 @@ private fun heroMobileBelowSectionHeightHint(
|
|||
hasContinueWatchingItems: Boolean,
|
||||
continueWatchingStyle: ContinueWatchingSectionStyle,
|
||||
continueWatchingLayout: ContinueWatchingLayout,
|
||||
continueWatchingCardHeight: Dp,
|
||||
bottomNavigationOverlayHeight: Dp,
|
||||
): Dp? {
|
||||
if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null
|
||||
|
||||
val sectionHeight = when (continueWatchingStyle) {
|
||||
ContinueWatchingSectionStyle.Card -> continueWatchingCardHeight + 56.dp
|
||||
ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp
|
||||
ContinueWatchingSectionStyle.Poster ->
|
||||
continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.nuvio.app.features.home.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
|
|
@ -32,19 +33,27 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
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.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
|
||||
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
||||
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
|
|
@ -56,6 +65,17 @@ import kotlin.math.roundToInt
|
|||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
private val ContinueWatchingStatusBadgeShape = RoundedCornerShape(4.dp)
|
||||
private val ContinueWatchingNewEpisodeBadgeColor = Color(0xFF1D4ED8)
|
||||
private val ContinueWatchingNewSeasonBadgeColor = Color(0xFFB45309)
|
||||
private const val ContinueWatchingLandscapeCardScale = 1.2f
|
||||
|
||||
internal fun continueWatchingLandscapeCardWidth(basePosterWidthDp: Int): Dp =
|
||||
(landscapePosterWidth(basePosterWidthDp).value * ContinueWatchingLandscapeCardScale).dp
|
||||
|
||||
internal fun continueWatchingLandscapeCardHeight(basePosterWidthDp: Int): Dp =
|
||||
landscapePosterHeightForWidth(continueWatchingLandscapeCardWidth(basePosterWidthDp))
|
||||
|
||||
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
||||
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
|
||||
|
||||
|
|
@ -123,6 +143,42 @@ private fun ContinueWatchingItem.continueWatchingPosterArtworkUrl(
|
|||
)
|
||||
}
|
||||
|
||||
private fun ContinueWatchingItem.continueWatchingCardArtworkUrl(
|
||||
useEpisodeThumbnails: Boolean,
|
||||
preferBackdropForNextUp: Boolean,
|
||||
): String? = when {
|
||||
isNextUp && preferBackdropForNextUp -> firstNonBlank(
|
||||
background,
|
||||
poster,
|
||||
episodeThumbnail,
|
||||
imageUrl,
|
||||
)
|
||||
isNextUp && useEpisodeThumbnails -> firstNonBlank(
|
||||
episodeThumbnail,
|
||||
background,
|
||||
poster,
|
||||
imageUrl,
|
||||
)
|
||||
isNextUp -> firstNonBlank(
|
||||
background,
|
||||
poster,
|
||||
episodeThumbnail,
|
||||
imageUrl,
|
||||
)
|
||||
useEpisodeThumbnails -> firstNonBlank(
|
||||
episodeThumbnail,
|
||||
background,
|
||||
poster,
|
||||
imageUrl,
|
||||
)
|
||||
else -> firstNonBlank(
|
||||
background,
|
||||
poster,
|
||||
episodeThumbnail,
|
||||
imageUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun firstNonBlank(vararg values: String?): String? =
|
||||
values.firstOrNull { value -> !value.isNullOrBlank() }?.trim()
|
||||
|
||||
|
|
@ -202,6 +258,13 @@ private fun HomeContinueWatchingSectionContent(
|
|||
key = { item -> item.videoId },
|
||||
) { item ->
|
||||
when (style) {
|
||||
ContinueWatchingSectionStyle.Card -> ContinueWatchingCard(
|
||||
item = item,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
onClick = onItemClick?.let { { it(item) } },
|
||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||
)
|
||||
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
|
||||
item = item,
|
||||
layout = layout,
|
||||
|
|
@ -246,33 +309,108 @@ fun ContinueWatchingStylePreview(
|
|||
modifier: Modifier = Modifier,
|
||||
isSelected: Boolean = false,
|
||||
) {
|
||||
val borderColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)
|
||||
}
|
||||
val backgroundColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
val previewBorder = if (style == ContinueWatchingSectionStyle.Wide) {
|
||||
BorderStroke(
|
||||
1.dp,
|
||||
if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(backgroundColor)
|
||||
.border(1.dp, borderColor, RoundedCornerShape(12.dp))
|
||||
.then(
|
||||
if (previewBorder != null) {
|
||||
Modifier.border(previewBorder, RoundedCornerShape(12.dp))
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
when (style) {
|
||||
ContinueWatchingSectionStyle.Card -> CardStylePreview()
|
||||
ContinueWatchingSectionStyle.Wide -> WideCardPreview()
|
||||
ContinueWatchingSectionStyle.Poster -> PosterCardPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardStylePreview() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to Color.Transparent,
|
||||
0.60f to MaterialTheme.colorScheme.background.copy(alpha = 0.45f),
|
||||
1.0f to MaterialTheme.colorScheme.background.copy(alpha = 0.90f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(5.dp)
|
||||
.width(26.dp)
|
||||
.height(9.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.80f)),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(7.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(28.dp)
|
||||
.height(5.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(Color.White.copy(alpha = 0.55f)),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(58.dp)
|
||||
.height(7.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(Color.White.copy(alpha = 0.75f)),
|
||||
)
|
||||
}
|
||||
NuvioProgressBar(
|
||||
progress = 0.55f,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 5.dp, vertical = 3.dp)
|
||||
.fillMaxWidth(),
|
||||
height = 3.dp,
|
||||
trackColor = Color.Black.copy(alpha = 0.30f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WideCardPreview() {
|
||||
Row(
|
||||
|
|
@ -385,6 +523,280 @@ private fun PosterCardPreview() {
|
|||
}
|
||||
}
|
||||
|
||||
private data class ContinueWatchingLandscapeCardMetrics(
|
||||
val width: Dp,
|
||||
val cornerRadius: Dp,
|
||||
val contentPadding: Dp,
|
||||
val textGap: Dp,
|
||||
val badgeInset: Dp,
|
||||
val badgeHorizontalPadding: Dp,
|
||||
val badgeVerticalPadding: Dp,
|
||||
val progressHorizontalPadding: Dp,
|
||||
val progressBottomPadding: Dp,
|
||||
val progressHeight: Dp,
|
||||
val titleTextSize: TextUnit,
|
||||
val metaTextSize: TextUnit,
|
||||
val badgeTextSize: TextUnit,
|
||||
)
|
||||
|
||||
private fun continueWatchingLandscapeCardMetrics(
|
||||
basePosterWidthDp: Int,
|
||||
cornerRadiusDp: Int,
|
||||
): ContinueWatchingLandscapeCardMetrics {
|
||||
val width = continueWatchingLandscapeCardWidth(basePosterWidthDp)
|
||||
return when {
|
||||
basePosterWidthDp <= 108 -> ContinueWatchingLandscapeCardMetrics(
|
||||
width = width,
|
||||
cornerRadius = cornerRadiusDp.dp,
|
||||
contentPadding = 8.dp,
|
||||
textGap = 1.dp,
|
||||
badgeInset = 6.dp,
|
||||
badgeHorizontalPadding = 6.dp,
|
||||
badgeVerticalPadding = 2.dp,
|
||||
progressHorizontalPadding = 8.dp,
|
||||
progressBottomPadding = 3.dp,
|
||||
progressHeight = 3.dp,
|
||||
titleTextSize = 12.sp,
|
||||
metaTextSize = 9.sp,
|
||||
badgeTextSize = 8.sp,
|
||||
)
|
||||
basePosterWidthDp <= 120 -> ContinueWatchingLandscapeCardMetrics(
|
||||
width = width,
|
||||
cornerRadius = cornerRadiusDp.dp,
|
||||
contentPadding = 9.dp,
|
||||
textGap = 1.dp,
|
||||
badgeInset = 6.dp,
|
||||
badgeHorizontalPadding = 7.dp,
|
||||
badgeVerticalPadding = 3.dp,
|
||||
progressHorizontalPadding = 8.dp,
|
||||
progressBottomPadding = 3.dp,
|
||||
progressHeight = 3.dp,
|
||||
titleTextSize = 13.sp,
|
||||
metaTextSize = 10.sp,
|
||||
badgeTextSize = 9.sp,
|
||||
)
|
||||
else -> ContinueWatchingLandscapeCardMetrics(
|
||||
width = width,
|
||||
cornerRadius = cornerRadiusDp.dp,
|
||||
contentPadding = 10.dp,
|
||||
textGap = 2.dp,
|
||||
badgeInset = 7.dp,
|
||||
badgeHorizontalPadding = 7.dp,
|
||||
badgeVerticalPadding = 3.dp,
|
||||
progressHorizontalPadding = 9.dp,
|
||||
progressBottomPadding = 4.dp,
|
||||
progressHeight = 3.dp,
|
||||
titleTextSize = 14.sp,
|
||||
metaTextSize = 10.sp,
|
||||
badgeTextSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ContinueWatchingCard(
|
||||
item: ContinueWatchingItem,
|
||||
useEpisodeThumbnails: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||
val cardMetrics = remember(posterCardStyle.widthDp, posterCardStyle.cornerRadiusDp) {
|
||||
continueWatchingLandscapeCardMetrics(
|
||||
basePosterWidthDp = posterCardStyle.widthDp,
|
||||
cornerRadiusDp = posterCardStyle.cornerRadiusDp,
|
||||
)
|
||||
}
|
||||
val todayIsoDate = CurrentDateProvider.todayIsoDate()
|
||||
val compactAirDateText = if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) {
|
||||
computeAirDateBadgeText(item.released, todayIsoDate, compact = true)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val preferBackdropForNextUp = item.isNextUp && compactAirDateText != null && !item.isReleaseAlert
|
||||
val imageUrl = item.continueWatchingCardArtworkUrl(
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
preferBackdropForNextUp = preferBackdropForNextUp,
|
||||
)
|
||||
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||
val episodeCode = if (item.seasonNumber != null && item.episodeNumber != null) {
|
||||
stringResource(Res.string.streams_episode_badge, item.seasonNumber, item.episodeNumber)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val episodeTitle = item.episodeTitle?.trim()?.takeIf { it.isNotBlank() } ?: compactAirDateText
|
||||
val badgeText = continueWatchingCardBadgeText(item = item, compactAirDateText = compactAirDateText)
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
val badgeBackground = when {
|
||||
item.isNewSeasonRelease -> ContinueWatchingNewSeasonBadgeColor
|
||||
item.isReleaseAlert -> ContinueWatchingNewEpisodeBadgeColor
|
||||
else -> backgroundColor.copy(alpha = 0.80f)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(cardMetrics.width)
|
||||
.aspectRatio(PosterLandscapeAspectRatio)
|
||||
.clip(RoundedCornerShape(cardMetrics.cornerRadius))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = cloudLibraryDisplayArtworkUrl(imageUrl),
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
|
||||
val startY = size.height * 0.45f
|
||||
val gradient = Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to Color.Transparent,
|
||||
0.60f to backgroundColor.copy(alpha = 0.70f),
|
||||
1.0f to backgroundColor.copy(alpha = 0.95f),
|
||||
),
|
||||
startY = startY,
|
||||
endY = size.height,
|
||||
)
|
||||
|
||||
drawRect(
|
||||
brush = gradient,
|
||||
topLeft = Offset(-2f, startY),
|
||||
size = Size(size.width + 4f, (size.height - startY) + 4f),
|
||||
)
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
if (!posterCardStyle.hideLabelsEnabled) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(cardMetrics.contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(cardMetrics.textGap),
|
||||
) {
|
||||
if (episodeCode != null) {
|
||||
Text(
|
||||
text = episodeCode,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = cardMetrics.metaTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
fontSize = cardMetrics.titleTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (episodeTitle != null) {
|
||||
Text(
|
||||
text = episodeTitle,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = cardMetrics.metaTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(cardMetrics.badgeInset)
|
||||
.clip(ContinueWatchingStatusBadgeShape)
|
||||
.background(badgeBackground)
|
||||
.padding(
|
||||
horizontal = cardMetrics.badgeHorizontalPadding,
|
||||
vertical = cardMetrics.badgeVerticalPadding,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = badgeText,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = cardMetrics.badgeTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (item.progressFraction > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(
|
||||
horizontal = cardMetrics.progressHorizontalPadding,
|
||||
vertical = cardMetrics.progressBottomPadding,
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.height(cardMetrics.progressHeight)
|
||||
.background(Color.Black.copy(alpha = 0.30f)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(item.progressFraction.coerceIn(0f, 1f))
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun continueWatchingCardBadgeText(
|
||||
item: ContinueWatchingItem,
|
||||
compactAirDateText: String?,
|
||||
): String {
|
||||
if (item.progressFraction > 0f) {
|
||||
if (item.durationMs <= 0L) {
|
||||
return stringResource(
|
||||
Res.string.home_continue_watching_watched,
|
||||
"${continueWatchingProgressPercent(item.progressFraction)}%",
|
||||
)
|
||||
}
|
||||
val effectivePositionMs = when {
|
||||
item.resumePositionMs > 0L -> item.resumePositionMs
|
||||
else -> (item.durationMs * item.progressFraction.coerceIn(0f, 1f)).toLong()
|
||||
}
|
||||
val remainingMinutes = ((item.durationMs - effectivePositionMs).coerceAtLeast(0L) / 60_000L)
|
||||
.coerceAtLeast(1L)
|
||||
val hours = remainingMinutes / 60L
|
||||
val minutes = remainingMinutes % 60L
|
||||
return if (hours > 0L) {
|
||||
stringResource(Res.string.home_continue_watching_hours_minutes_left, hours, minutes)
|
||||
} else {
|
||||
stringResource(Res.string.home_continue_watching_minutes_left, remainingMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
return when {
|
||||
item.isReleaseAlert && item.isNewSeasonRelease -> stringResource(Res.string.cw_new_season)
|
||||
item.isReleaseAlert -> stringResource(Res.string.cw_new_episode)
|
||||
compactAirDateText != null -> compactAirDateText
|
||||
else -> stringResource(Res.string.home_continue_watching_up_next)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ContinueWatchingWideCard(
|
||||
|
|
@ -532,11 +944,6 @@ private fun ContinueWatchingPosterCard(
|
|||
.height(layout.posterCardHeight)
|
||||
.clip(RoundedCornerShape(layout.cardRadius))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.border(
|
||||
width = 1.5.dp,
|
||||
color = Color.White.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(layout.cardRadius),
|
||||
)
|
||||
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
) {
|
||||
val imageUrl = item.continueWatchingPosterArtworkUrl(useEpisodeThumbnails)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.features.home.components.ContinueWatchingStylePreview
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
|
|
@ -54,6 +55,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode
|
|||
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_card
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_card_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide
|
||||
|
|
@ -205,7 +208,7 @@ private fun ContinueWatchingStyleSelector(
|
|||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(if (isTablet) 12.dp else 8.dp),
|
||||
) {
|
||||
ContinueWatchingSectionStyle.entries.forEach { style ->
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
|
|
@ -227,6 +230,16 @@ private fun ContinueWatchingStyleOption(
|
|||
isTablet: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val optionBorder = if (style == ContinueWatchingSectionStyle.Wide) {
|
||||
androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -237,36 +250,34 @@ private fun ContinueWatchingStyleOption(
|
|||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
border = optionBorder,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp),
|
||||
.padding(horizontal = if (isTablet) 12.dp else 8.dp, vertical = if (isTablet) 14.dp else 10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(if (isTablet) 8.dp else 6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 4.dp),
|
||||
.padding(bottom = if (isTablet) 4.dp else 0.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.alpha(if (selected) 1f else 0f),
|
||||
modifier = Modifier
|
||||
.size(if (isTablet) 24.dp else 18.dp)
|
||||
.alpha(if (selected) 1f else 0f),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(148.dp),
|
||||
.height(if (isTablet) 96.dp else 66.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ContinueWatchingStylePreview(
|
||||
|
|
@ -276,14 +287,18 @@ private fun ContinueWatchingStyleOption(
|
|||
}
|
||||
Text(
|
||||
text = stringResource(style.labelRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = if (isTablet) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.labelMedium,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(style.descriptionRes),
|
||||
style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium,
|
||||
style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -291,12 +306,14 @@ private fun ContinueWatchingStyleOption(
|
|||
|
||||
private val ContinueWatchingSectionStyle.labelRes: StringResource
|
||||
get() = when (this) {
|
||||
ContinueWatchingSectionStyle.Card -> Res.string.settings_continue_watching_style_card
|
||||
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide
|
||||
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster
|
||||
}
|
||||
|
||||
private val ContinueWatchingSectionStyle.descriptionRes: StringResource
|
||||
get() = when (this) {
|
||||
ContinueWatchingSectionStyle.Card -> Res.string.settings_continue_watching_style_card_description
|
||||
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description
|
||||
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import kotlinx.serialization.json.Json
|
|||
@Serializable
|
||||
private data class StoredContinueWatchingPreferences(
|
||||
val isVisible: Boolean = true,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Card,
|
||||
val upNextFromFurthestEpisode: Boolean = true,
|
||||
@SerialName("use_episode_thumbnails_in_cw")
|
||||
val useEpisodeThumbnails: Boolean = true,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress"
|
|||
|
||||
@Serializable
|
||||
enum class ContinueWatchingSectionStyle {
|
||||
Card,
|
||||
Wide,
|
||||
Poster,
|
||||
}
|
||||
|
|
@ -179,7 +180,7 @@ data class ContinueWatchingItem(
|
|||
|
||||
data class ContinueWatchingPreferencesUiState(
|
||||
val isVisible: Boolean = true,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Card,
|
||||
val upNextFromFurthestEpisode: Boolean = true,
|
||||
val useEpisodeThumbnails: Boolean = true,
|
||||
val showUnairedNextUp: Boolean = true,
|
||||
|
|
|
|||
Loading…
Reference in a new issue