mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-26 02:52:53 +00:00
feat: sharedElements transition for cast avatars
This commit is contained in:
parent
c95c498d9b
commit
ff61659e6b
6 changed files with 165 additions and 32 deletions
|
|
@ -1,6 +1,8 @@
|
|||
package com.nuvio.app
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
|
|
@ -168,6 +170,7 @@ data class DetailRoute(val type: String, val id: String)
|
|||
data class PersonDetailRoute(
|
||||
val personId: Int,
|
||||
val personName: String,
|
||||
val personPhoto: String? = null,
|
||||
val preferCrew: Boolean = false,
|
||||
)
|
||||
|
||||
|
|
@ -387,7 +390,7 @@ fun App() {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
private fun MainAppContent(
|
||||
onSwitchProfile: () -> Unit = {},
|
||||
|
|
@ -619,11 +622,12 @@ private fun MainAppContent(
|
|||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = TabsRoute,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
SharedTransitionLayout {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = TabsRoute,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
composable<TabsRoute> {
|
||||
PlatformBackHandler(
|
||||
enabled = selectedTab != AppScreenTab.Home,
|
||||
|
|
@ -769,6 +773,7 @@ private fun MainAppContent(
|
|||
PersonDetailRoute(
|
||||
personId = tmdbId,
|
||||
personName = person.name,
|
||||
personPhoto = person.photo,
|
||||
preferCrew = person.role?.let {
|
||||
it.equals("Director", ignoreCase = true) ||
|
||||
it.equals("Writer", ignoreCase = true) ||
|
||||
|
|
@ -791,6 +796,8 @@ private fun MainAppContent(
|
|||
)
|
||||
}
|
||||
},
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
|
@ -799,6 +806,7 @@ private fun MainAppContent(
|
|||
PersonDetailScreen(
|
||||
personId = route.personId,
|
||||
personName = route.personName,
|
||||
initialProfilePhoto = route.personPhoto,
|
||||
preferCrew = route.preferCrew,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenMeta = { preview ->
|
||||
|
|
@ -822,6 +830,8 @@ private fun MainAppContent(
|
|||
)
|
||||
}
|
||||
},
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
|
@ -1290,6 +1300,7 @@ private fun MainAppContent(
|
|||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NuvioPosterActionSheet(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
internal fun castAvatarSharedTransitionKey(personId: Int): String = "cast-avatar:$personId"
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
|
|
@ -96,6 +99,7 @@ import com.nuvio.app.features.watching.application.WatchingState
|
|||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
fun MetaDetailsScreen(
|
||||
type: String,
|
||||
id: String,
|
||||
|
|
@ -105,6 +109,8 @@ fun MetaDetailsScreen(
|
|||
onOpenMeta: ((MetaPreview) -> Unit)? = null,
|
||||
onCastClick: ((MetaPerson) -> Unit)? = null,
|
||||
onCompanyClick: ((MetaCompany, String) -> Unit)? = null,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -639,6 +645,8 @@ fun MetaDetailsScreen(
|
|||
onOpenMeta = onOpenMeta,
|
||||
onCastClick = onCastClick,
|
||||
onCompanyClick = onCompanyClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding))
|
||||
|
|
@ -877,6 +885,7 @@ fun MetaDetailsScreen(
|
|||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
private fun ConfiguredMetaSections(
|
||||
settings: MetaScreenSettingsUiState,
|
||||
meta: MetaDetails,
|
||||
|
|
@ -912,6 +921,8 @@ private fun ConfiguredMetaSections(
|
|||
onOpenMeta: ((MetaPreview) -> Unit)?,
|
||||
onCastClick: ((MetaPerson) -> Unit)?,
|
||||
onCompanyClick: ((MetaCompany, String) -> Unit)?,
|
||||
sharedTransitionScope: SharedTransitionScope?,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?,
|
||||
) {
|
||||
val enabledItems = settings.items.filter { it.enabled }
|
||||
|
||||
|
|
@ -954,7 +965,13 @@ private fun ConfiguredMetaSections(
|
|||
}
|
||||
}
|
||||
MetaScreenSectionKey.CAST -> {
|
||||
DetailCastSection(cast = meta.cast, showHeader = showHeader, onCastClick = onCastClick)
|
||||
DetailCastSection(
|
||||
cast = meta.cast,
|
||||
showHeader = showHeader,
|
||||
onCastClick = onCastClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
}
|
||||
MetaScreenSectionKey.COMMENTS -> {
|
||||
if (shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank())) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -64,12 +66,16 @@ private sealed interface PersonDetailUiState {
|
|||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
fun PersonDetailScreen(
|
||||
personId: Int,
|
||||
personName: String,
|
||||
initialProfilePhoto: String? = null,
|
||||
preferCrew: Boolean = false,
|
||||
onBack: () -> Unit,
|
||||
onOpenMeta: (MetaPreview) -> Unit,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var uiState by remember(personId) { mutableStateOf<PersonDetailUiState>(PersonDetailUiState.Loading) }
|
||||
|
|
@ -92,25 +98,29 @@ fun PersonDetailScreen(
|
|||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = uiState,
|
||||
label = "PersonDetailCrossfade",
|
||||
) { state ->
|
||||
when (state) {
|
||||
is PersonDetailUiState.Loading -> PersonDetailSkeleton(personName = personName)
|
||||
is PersonDetailUiState.Error -> PersonDetailError(
|
||||
message = state.message,
|
||||
onRetry = {
|
||||
uiState = PersonDetailUiState.Loading
|
||||
// Retry will be triggered by the LaunchedEffect above if we reset
|
||||
},
|
||||
)
|
||||
is PersonDetailUiState.Success -> PersonDetailContent(
|
||||
person = state.personDetail,
|
||||
onOpenMeta = onOpenMeta,
|
||||
)
|
||||
when (val state = uiState) {
|
||||
is PersonDetailUiState.Loading -> PersonDetailSkeleton(
|
||||
personId = personId,
|
||||
personName = personName,
|
||||
profilePhoto = initialProfilePhoto,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
is PersonDetailUiState.Error -> PersonDetailError(
|
||||
message = state.message,
|
||||
onRetry = {
|
||||
uiState = PersonDetailUiState.Loading
|
||||
// Retry will be triggered by the LaunchedEffect above if we reset
|
||||
},
|
||||
)
|
||||
is PersonDetailUiState.Success -> PersonDetailContent(
|
||||
person = state.personDetail,
|
||||
onOpenMeta = onOpenMeta,
|
||||
initialProfilePhoto = initialProfilePhoto,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Back button overlaid on top
|
||||
IconButton(
|
||||
|
|
@ -130,9 +140,13 @@ fun PersonDetailScreen(
|
|||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
private fun PersonDetailContent(
|
||||
person: PersonDetail,
|
||||
onOpenMeta: (MetaPreview) -> Unit,
|
||||
initialProfilePhoto: String? = null,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
) {
|
||||
val accentColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
|
|
@ -223,6 +237,9 @@ private fun PersonDetailContent(
|
|||
HeroSection(
|
||||
person = person,
|
||||
collapseProgress = collapseProgress,
|
||||
fallbackProfilePhoto = initialProfilePhoto,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
|
||||
if (popularCredits.isNotEmpty()) {
|
||||
|
|
@ -268,13 +285,30 @@ private const val HERO_COLLAPSE_SCROLL_RANGE = 220f
|
|||
private const val HAPTIC_TRIGGER_SCROLL_THRESHOLD_PX = 56
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
private fun HeroSection(
|
||||
person: PersonDetail,
|
||||
collapseProgress: Float = 0f,
|
||||
fallbackProfilePhoto: String? = null,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
) {
|
||||
val avatarSize = lerp(140.dp, 72.dp, collapseProgress)
|
||||
val heroScale = 1f - (collapseProgress * 0.12f)
|
||||
val heroAlpha = 1f - (collapseProgress * 0.35f)
|
||||
val avatarUrl = person.profilePhoto?.takeIf { it.isNotBlank() } ?: fallbackProfilePhoto
|
||||
val avatarSharedElementModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) {
|
||||
with(sharedTransitionScope) {
|
||||
Modifier.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(
|
||||
key = castAvatarSharedTransitionKey(person.tmdbId),
|
||||
),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
|
@ -291,14 +325,15 @@ private fun HeroSection(
|
|||
// Profile Photo
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.then(avatarSharedElementModifier)
|
||||
.size(avatarSize)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (!person.profilePhoto.isNullOrBlank()) {
|
||||
if (!avatarUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = person.profilePhoto,
|
||||
model = avatarUrl,
|
||||
contentDescription = person.name,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
|
|
@ -378,7 +413,14 @@ private fun HeroSection(
|
|||
// ─── Loading / Error States ───
|
||||
|
||||
@Composable
|
||||
private fun PersonDetailSkeleton(personName: String) {
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
private fun PersonDetailSkeleton(
|
||||
personId: Int,
|
||||
personName: String,
|
||||
profilePhoto: String? = null,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
) {
|
||||
val accentColor = MaterialTheme.colorScheme.primary
|
||||
val accentGradient = remember(accentColor) {
|
||||
Brush.verticalGradient(
|
||||
|
|
@ -416,12 +458,41 @@ private fun PersonDetailSkeleton(personName: String) {
|
|||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val avatarSharedElementModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) {
|
||||
with(sharedTransitionScope) {
|
||||
Modifier.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(
|
||||
key = castAvatarSharedTransitionKey(personId),
|
||||
),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.then(avatarSharedElementModifier)
|
||||
.size(140.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
)
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (!profilePhoto.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = profilePhoto,
|
||||
contentDescription = personName,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = personName.firstOrNull()?.uppercase() ?: "?",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.nuvio.app.features.details.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -27,13 +30,17 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.features.details.MetaPerson
|
||||
import com.nuvio.app.features.details.castAvatarSharedTransitionKey
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
fun DetailCastSection(
|
||||
cast: List<MetaPerson>,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
onCastClick: ((MetaPerson) -> Unit)? = null,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
) {
|
||||
if (cast.isEmpty()) return
|
||||
|
||||
|
|
@ -55,6 +62,8 @@ fun DetailCastSection(
|
|||
CastItem(
|
||||
person = person,
|
||||
sizing = sizing,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
onClick = if (onCastClick != null && person.tmdbId != null && person.tmdbId > 0) {
|
||||
{ onCastClick(person) }
|
||||
} else {
|
||||
|
|
@ -68,12 +77,33 @@ fun DetailCastSection(
|
|||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
private fun CastItem(
|
||||
person: MetaPerson,
|
||||
modifier: Modifier = Modifier,
|
||||
sizing: CastSectionSizing,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val avatarSharedElementModifier = if (
|
||||
sharedTransitionScope != null &&
|
||||
animatedVisibilityScope != null &&
|
||||
person.tmdbId != null &&
|
||||
person.tmdbId > 0
|
||||
) {
|
||||
with(sharedTransitionScope) {
|
||||
Modifier.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(
|
||||
key = castAvatarSharedTransitionKey(person.tmdbId),
|
||||
),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.width(sizing.itemWidth)
|
||||
|
|
@ -83,6 +113,7 @@ private fun CastItem(
|
|||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.then(avatarSharedElementModifier)
|
||||
.size(sizing.avatarSize)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
CURRENT_PROJECT_VERSION=18
|
||||
MARKETING_VERSION=0.1.0-alpha18
|
||||
CURRENT_PROJECT_VERSION=19
|
||||
MARKETING_VERSION=0.1.0-alpha19
|
||||
Loading…
Reference in a new issue