diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index d70eefe09..26b3f1e77 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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, + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt new file mode 100644 index 000000000..62f7f2ef0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -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, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 01b9aa4ac..7cd0f0bc2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -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 },