mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-14 03:27:58 +00:00
feat: Enhance MetaDetailsScreen with DetailFloatingHeader and improve DetailHero for dynamic height adjustment
This commit is contained in:
parent
8cb71ff91a
commit
80751fe25b
3 changed files with 335 additions and 108 deletions
|
|
@ -1,6 +1,8 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -8,34 +10,34 @@ import androidx.compose.foundation.layout.Box
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.core.ui.nuvioPlatformExtraBottomPadding
|
||||
import com.nuvio.app.features.details.components.DetailActionButtons
|
||||
import com.nuvio.app.features.details.components.DetailCastSection
|
||||
import com.nuvio.app.features.details.components.DetailFloatingHeader
|
||||
import com.nuvio.app.features.details.components.DetailHero
|
||||
import com.nuvio.app.features.details.components.DetailMetaInfo
|
||||
import com.nuvio.app.features.details.components.DetailSeriesContent
|
||||
|
|
@ -119,6 +121,13 @@ fun MetaDetailsScreen(
|
|||
val isSaved = remember(libraryUiState.items, meta.id) {
|
||||
libraryUiState.items.any { it.id == meta.id }
|
||||
}
|
||||
val toggleSaved = remember(meta) {
|
||||
{
|
||||
LibraryRepository.toggleSaved(
|
||||
meta.toLibraryItem(savedAtEpochMs = 0L),
|
||||
)
|
||||
}
|
||||
}
|
||||
val movieProgress = watchProgressUiState.byVideoId[meta.id]
|
||||
?.takeUnless { it.isCompleted }
|
||||
val seriesAction = remember(watchProgressUiState.entries, meta) {
|
||||
|
|
@ -137,119 +146,161 @@ fun MetaDetailsScreen(
|
|||
}
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
DetailHero(meta = meta, scrollOffset = scrollState.value)
|
||||
val density = LocalDensity.current
|
||||
val safeAreaTopPx = with(density) {
|
||||
WindowInsets.statusBars
|
||||
.asPaddingValues()
|
||||
.calculateTopPadding()
|
||||
.toPx()
|
||||
}
|
||||
var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) }
|
||||
val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f)
|
||||
val headerTarget = if (heroHeightPx > 0 && scrollState.value > thresholdPx) 1f else 0f
|
||||
val headerProgress by animateFloatAsState(
|
||||
targetValue = headerTarget,
|
||||
animationSpec = tween(
|
||||
durationMillis = if (headerTarget > 0f) 150 else 100,
|
||||
easing = LinearOutSlowInEasing,
|
||||
),
|
||||
label = "detail_floating_header_progress",
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
DetailActionButtons(
|
||||
playLabel = playButtonLabel,
|
||||
saveLabel = if (isSaved) "Saved" else "Save",
|
||||
onPlayClick = {
|
||||
when {
|
||||
meta.type == "series" && seriesAction != null -> {
|
||||
onPlay?.invoke(
|
||||
meta.type,
|
||||
seriesAction.videoId,
|
||||
meta.id,
|
||||
meta.type,
|
||||
meta.name,
|
||||
meta.logo,
|
||||
meta.poster,
|
||||
meta.background,
|
||||
seriesAction.seasonNumber,
|
||||
seriesAction.episodeNumber,
|
||||
seriesAction.episodeTitle,
|
||||
seriesAction.episodeThumbnail,
|
||||
seriesAction.resumePositionMs,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
onPlay?.invoke(
|
||||
meta.type,
|
||||
meta.id,
|
||||
meta.id,
|
||||
meta.type,
|
||||
meta.name,
|
||||
meta.logo,
|
||||
meta.poster,
|
||||
meta.background,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
movieProgress?.lastPositionMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveClick = {
|
||||
LibraryRepository.toggleSaved(
|
||||
meta.toLibraryItem(savedAtEpochMs = 0L),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
DetailMetaInfo(meta = meta)
|
||||
|
||||
DetailCastSection(cast = meta.cast)
|
||||
|
||||
DetailSeriesContent(
|
||||
DetailHero(
|
||||
meta = meta,
|
||||
progressByVideoId = watchProgressUiState.byVideoId,
|
||||
onEpisodeClick = { video ->
|
||||
val season = video.season
|
||||
val episode = video.episode
|
||||
val playbackVideoId = buildPlaybackVideoId(
|
||||
parentMetaId = meta.id,
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
fallbackVideoId = video.id,
|
||||
)
|
||||
val savedProgress = watchProgressUiState.byVideoId[playbackVideoId]
|
||||
?.takeUnless { it.isCompleted }
|
||||
onPlay?.invoke(
|
||||
meta.type,
|
||||
playbackVideoId,
|
||||
meta.id,
|
||||
meta.type,
|
||||
meta.name,
|
||||
meta.logo,
|
||||
meta.poster,
|
||||
meta.background,
|
||||
season,
|
||||
episode,
|
||||
video.title,
|
||||
video.thumbnail,
|
||||
savedProgress?.lastPositionMs,
|
||||
)
|
||||
},
|
||||
scrollOffset = scrollState.value,
|
||||
onHeightChanged = { heroHeightPx = it },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
DetailActionButtons(
|
||||
playLabel = playButtonLabel,
|
||||
saveLabel = if (isSaved) "Saved" else "Save",
|
||||
onPlayClick = {
|
||||
when {
|
||||
meta.type == "series" && seriesAction != null -> {
|
||||
onPlay?.invoke(
|
||||
meta.type,
|
||||
seriesAction.videoId,
|
||||
meta.id,
|
||||
meta.type,
|
||||
meta.name,
|
||||
meta.logo,
|
||||
meta.poster,
|
||||
meta.background,
|
||||
seriesAction.seasonNumber,
|
||||
seriesAction.episodeNumber,
|
||||
seriesAction.episodeTitle,
|
||||
seriesAction.episodeThumbnail,
|
||||
seriesAction.resumePositionMs,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
onPlay?.invoke(
|
||||
meta.type,
|
||||
meta.id,
|
||||
meta.id,
|
||||
meta.type,
|
||||
meta.name,
|
||||
meta.logo,
|
||||
meta.poster,
|
||||
meta.background,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
movieProgress?.lastPositionMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveClick = toggleSaved,
|
||||
)
|
||||
|
||||
DetailMetaInfo(meta = meta)
|
||||
|
||||
DetailCastSection(cast = meta.cast)
|
||||
|
||||
DetailSeriesContent(
|
||||
meta = meta,
|
||||
progressByVideoId = watchProgressUiState.byVideoId,
|
||||
onEpisodeClick = { video ->
|
||||
val season = video.season
|
||||
val episode = video.episode
|
||||
val playbackVideoId = buildPlaybackVideoId(
|
||||
parentMetaId = meta.id,
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
fallbackVideoId = video.id,
|
||||
)
|
||||
val savedProgress = watchProgressUiState.byVideoId[playbackVideoId]
|
||||
?.takeUnless { it.isCompleted }
|
||||
onPlay?.invoke(
|
||||
meta.type,
|
||||
playbackVideoId,
|
||||
meta.id,
|
||||
meta.type,
|
||||
meta.name,
|
||||
meta.logo,
|
||||
meta.poster,
|
||||
meta.background,
|
||||
season,
|
||||
episode,
|
||||
video.title,
|
||||
video.thumbnail,
|
||||
savedProgress?.lastPositionMs,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
if (headerProgress <= 0.05f) {
|
||||
NuvioBackButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.padding(
|
||||
start = 12.dp,
|
||||
top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp,
|
||||
),
|
||||
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.5f),
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
||||
DetailFloatingHeader(
|
||||
meta = meta,
|
||||
isSaved = isSaved,
|
||||
progress = headerProgress,
|
||||
onBack = onBack,
|
||||
onToggleSaved = toggleSaved,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back button overlay
|
||||
NuvioBackButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top))
|
||||
.padding(start = 12.dp, top = 8.dp)
|
||||
,
|
||||
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.5f),
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (requestedMeta == null) {
|
||||
NuvioBackButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.padding(
|
||||
start = 12.dp,
|
||||
top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp,
|
||||
),
|
||||
containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.5f),
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
package com.nuvio.app.features.details.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.BookmarkBorder
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.isIos
|
||||
|
||||
@Composable
|
||||
fun DetailFloatingHeader(
|
||||
meta: MetaDetails,
|
||||
isSaved: Boolean,
|
||||
progress: Float,
|
||||
onBack: () -> Unit,
|
||||
onToggleSaved: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val safeAreaTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val headerTopPadding = (safeAreaTop - 6.dp).coerceAtLeast(safeAreaTop * 0.8f)
|
||||
val interactive = progress > 0.05f
|
||||
val surfaceColor = if (isIos) {
|
||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.84f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.background
|
||||
}
|
||||
var logoLoadError by remember(meta.id, meta.logo) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
alpha = progress
|
||||
translationY = lerp((-20).dp, 0.dp, progress).toPx()
|
||||
shadowElevation = 4.dp.toPx()
|
||||
shape = RectangleShape
|
||||
},
|
||||
) {
|
||||
val logoWidth = (maxWidth * 0.6f).coerceAtMost(240.dp)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clipToBounds()
|
||||
.background(surfaceColor),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = headerTopPadding, start = 16.dp, end = 16.dp)
|
||||
.height(56.dp)
|
||||
.graphicsLayer { alpha = progress },
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (interactive) {
|
||||
NuvioBackButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.size(40.dp),
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
buttonSize = 40.dp,
|
||||
iconSize = 24.dp,
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.size(40.dp))
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (meta.logo != null && !logoLoadError) {
|
||||
AsyncImage(
|
||||
model = meta.logo,
|
||||
contentDescription = "${meta.name} logo",
|
||||
modifier = Modifier
|
||||
.width(logoWidth)
|
||||
.widthIn(max = 240.dp)
|
||||
.height(42.dp),
|
||||
onError = { logoLoadError = true },
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = meta.name,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DetailFloatingHeaderAction(
|
||||
isSaved = isSaved,
|
||||
enabled = interactive,
|
||||
onClick = onToggleSaved,
|
||||
)
|
||||
}
|
||||
|
||||
if (isIos) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.height(0.5.dp)
|
||||
.background(Color.White.copy(alpha = 0.15f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailFloatingHeaderAction(
|
||||
isSaved: Boolean,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clickable(enabled = enabled, onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isSaved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
|
||||
contentDescription = if (isSaved) "Remove from Library" else "Add to Library",
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
|
@ -27,12 +28,14 @@ import com.nuvio.app.features.details.MetaDetails
|
|||
fun DetailHero(
|
||||
meta: MetaDetails,
|
||||
scrollOffset: Int = 0,
|
||||
onHeightChanged: (Int) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(0.75f)
|
||||
.onSizeChanged { onHeightChanged(it.height) }
|
||||
.graphicsLayer {
|
||||
clip = true
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue