feat(app): add shared bounds key functionality for home media navigation

This commit is contained in:
2026-05-02 13:10:34 +02:00
parent 64b1032b74
commit 557679861a
15 changed files with 253 additions and 89 deletions

View File

@@ -41,8 +41,10 @@ import dagger.hilt.android.AndroidEntryPoint
import hu.bbara.purefin.data.SessionBootstrapper
import hu.bbara.purefin.jellyfin.JellyfinAuthInterceptor
import hu.bbara.purefin.data.UserSessionRepository
import hu.bbara.purefin.navigation.LocalHomeMediaSharedBoundsKey
import hu.bbara.purefin.navigation.LocalNavigationBackStack
import hu.bbara.purefin.navigation.LocalNavigationManager
import hu.bbara.purefin.navigation.LocalSetHomeMediaSharedBoundsKey
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import hu.bbara.purefin.navigation.NavigationCommand
import hu.bbara.purefin.navigation.NavigationManager
@@ -158,6 +160,7 @@ class PurefinActivity : ComponentActivity() {
if (isLoggedIn) {
@Suppress("UNCHECKED_CAST")
val backStack = rememberNavBackStack(Route.Home) as NavBackStack<Route>
var homeMediaSharedBoundsKey by remember { mutableStateOf<String?>(null) }
val appEntryProvider =
entryProvider {
entryBuilders.forEach { builder -> builder() }
@@ -180,7 +183,11 @@ class PurefinActivity : ComponentActivity() {
CompositionLocalProvider(
LocalNavigationManager provides navigationManager,
LocalNavigationBackStack provides backStack.toList(),
LocalSharedTransitionScope provides this
LocalSharedTransitionScope provides this,
LocalHomeMediaSharedBoundsKey provides homeMediaSharedBoundsKey,
LocalSetHomeMediaSharedBoundsKey provides { key ->
homeMediaSharedBoundsKey = key
}
) {
NavDisplay(
backStack = backStack,

View File

@@ -18,4 +18,8 @@ val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { nu
val LocalNavSharedAnimatedVisibilityScope =
compositionLocalOf<AnimatedVisibilityScope?> { null }
val LocalHomeMediaSharedBoundsKey = compositionLocalOf<String?> { null }
val LocalSetHomeMediaSharedBoundsKey = staticCompositionLocalOf<(String) -> Unit> { {} }
const val HOME_SEARCH_SHARED_BOUNDS_KEY = "home_search_shared_bounds"

View File

@@ -27,14 +27,26 @@ fun EntryProviderScope<Route>.appRouteEntryBuilder() {
}
}
entry<Route.MovieRoute> {
CompositionLocalProvider(
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
) {
MovieScreen(movie = it.item)
}
}
entry<Route.SeriesRoute> {
CompositionLocalProvider(
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
) {
SeriesScreen(series = it.item)
}
}
entry<Route.EpisodeRoute> {
CompositionLocalProvider(
LocalNavSharedAnimatedVisibilityScope provides LocalNavAnimatedContentScope.current
) {
EpisodeScreen(episode = it.item)
}
}
entry<Route.LibraryRoute> {
LibraryScreen(library = it.library)
}

View File

@@ -0,0 +1,75 @@
package hu.bbara.purefin.ui.common.media
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import hu.bbara.purefin.navigation.LocalHomeMediaSharedBoundsKey
import hu.bbara.purefin.navigation.LocalNavSharedAnimatedVisibilityScope
import hu.bbara.purefin.navigation.LocalSetHomeMediaSharedBoundsKey
import hu.bbara.purefin.navigation.LocalSharedTransitionScope
import java.util.UUID
fun homeMediaSharedBoundsKey(origin: String, mediaId: UUID): String =
"home_media_${origin}_$mediaId"
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Modifier.homeMediaSharedBoundsSource(sharedBoundsKey: String): Modifier {
val selectedKey = LocalHomeMediaSharedBoundsKey.current
return homeMediaSharedBounds(sharedBoundsKey.takeIf { it == selectedKey })
}
@Composable
fun Modifier.homeMediaSharedBoundsDestination(): Modifier =
homeMediaSharedBounds(LocalHomeMediaSharedBoundsKey.current)
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun isHomeMediaSharedBoundsTransitionActive(): Boolean {
val sharedBoundsKey = LocalHomeMediaSharedBoundsKey.current
val sharedTransitionScope = LocalSharedTransitionScope.current
return sharedBoundsKey != null && sharedTransitionScope?.isTransitionActive == true
}
@Composable
fun rememberHomeMediaSharedBoundsClick(
sharedBoundsKey: String,
onClick: () -> Unit
): () -> Unit {
val selectSharedBoundsKey = LocalSetHomeMediaSharedBoundsKey.current
return remember(sharedBoundsKey, onClick, selectSharedBoundsKey) {
{
selectSharedBoundsKey(sharedBoundsKey)
onClick()
}
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun Modifier.homeMediaSharedBounds(sharedBoundsKey: String?): Modifier {
val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalNavSharedAnimatedVisibilityScope.current
if (
sharedBoundsKey == null ||
sharedTransitionScope == null ||
animatedVisibilityScope == null
) {
return this
}
return with(sharedTransitionScope) {
this@homeMediaSharedBounds.sharedBounds(
sharedContentState = rememberSharedContentState(key = sharedBoundsKey),
animatedVisibilityScope = animatedVisibilityScope,
enter = fadeIn(),
exit = fadeOut(),
resizeMode = SharedTransitionScope.ResizeMode.scaleToBounds()
)
}
}

View File

@@ -34,6 +34,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
import hu.bbara.purefin.ui.common.media.MediaHero
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsDestination
import hu.bbara.purefin.ui.common.media.isHomeMediaSharedBoundsTransitionActive
import hu.bbara.purefin.download.DownloadState
import hu.bbara.purefin.image.ImageUrlBuilder
import hu.bbara.purefin.navigation.EpisodeDto
@@ -117,11 +119,13 @@ private fun EpisodeScreenInternal(
onDownloadClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
if (!isHomeMediaTransitionActive) {
EpisodeTopBar(
shortcut = topBarShortcut,
onBack = onBack,
@@ -129,6 +133,7 @@ private fun EpisodeScreenInternal(
modifier = Modifier
)
}
}
) { innerPadding ->
Column(
modifier = Modifier
@@ -136,6 +141,7 @@ private fun EpisodeScreenInternal(
.verticalScroll(rememberScrollState())
) {
EpisodeHeroSection(episode = episode)
if (!isHomeMediaTransitionActive) {
EpisodeDetails(
episode = episode,
downloadState = downloadState,
@@ -147,6 +153,7 @@ private fun EpisodeScreenInternal(
)
}
}
}
}
@Composable
@@ -160,6 +167,7 @@ private fun EpisodeHeroSection(
Box(
modifier = modifier
.homeMediaSharedBoundsDestination()
.fillMaxWidth()
.height(sectionHeight)
) {

View File

@@ -26,17 +26,23 @@ import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
@Composable
internal fun ContinueWatchingCard(
item: MediaUiModel,
sharedBoundsKey: String,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
onMediaSelected(item)
}
Card(
onClick = { onMediaSelected(item) },
onClick = onClick,
shape = RoundedCornerShape(26.dp),
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
modifier = modifier.width(280.dp)
@@ -44,6 +50,7 @@ internal fun ContinueWatchingCard(
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.homeMediaSharedBoundsSource(sharedBoundsKey)
.fillMaxWidth()
.aspectRatio(16f / 9f)
.background(scheme.surfaceContainer)

View File

@@ -7,13 +7,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
import hu.bbara.purefin.ui.model.MediaUiModel
@Composable
@@ -42,9 +43,10 @@ fun ContinueWatchingSection(
modifier = Modifier.fillMaxWidth(),
state = listState
) {
items(items = items, key = { item -> item.id }) { item ->
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
ContinueWatchingCard(
item = item,
sharedBoundsKey = homeMediaSharedBoundsKey("continue-$index", item.id),
onMediaSelected = onMediaSelected
)
}

View File

@@ -26,18 +26,25 @@ import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.bar.MediaProgressBar
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
@Composable
internal fun SuggestionCard(
item: MediaUiModel,
sharedBoundsKey: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val description = item.description.trim()
val onCardClick = rememberHomeMediaSharedBoundsClick(
sharedBoundsKey = sharedBoundsKey,
onClick = onClick
)
ElevatedCard(
onClick = onClick,
onClick = onCardClick,
colors = CardDefaults.elevatedCardColors(containerColor = scheme.surfaceContainerLow),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
shape = RoundedCornerShape(30.dp),
@@ -45,6 +52,7 @@ internal fun SuggestionCard(
) {
Box(
modifier = Modifier
.homeMediaSharedBoundsSource(sharedBoundsKey)
.fillMaxWidth()
.aspectRatio(16f / 11f)
) {

View File

@@ -22,6 +22,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
import hu.bbara.purefin.ui.model.MediaUiModel
@Composable
@@ -49,9 +50,11 @@ fun SuggestionsSection(
pageSpacing = 16.dp,
modifier = Modifier.fillMaxWidth()
) { page ->
val item = items[page]
SuggestionCard(
item = items[page],
onClick = { onItemOpen(items[page]) }
item = item,
sharedBoundsKey = homeMediaSharedBoundsKey("suggestion-$page", item.id),
onClick = { onItemOpen(item) }
)
}
if (items.size > 1) {

View File

@@ -25,6 +25,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.badge.WatchStateBadge
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
import hu.bbara.purefin.ui.model.EpisodeUiModel
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.model.MovieUiModel
@@ -32,13 +34,17 @@ import hu.bbara.purefin.ui.model.MovieUiModel
@Composable
internal fun HomeBrowseCard(
uiModel: MediaUiModel,
sharedBoundsKey: String,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
onMediaSelected(uiModel)
}
Card(
onClick = { onMediaSelected(uiModel) },
onClick = onClick,
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
modifier = modifier.width(188.dp)
@@ -46,6 +52,7 @@ internal fun HomeBrowseCard(
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.homeMediaSharedBoundsSource(sharedBoundsKey)
.fillMaxWidth()
.aspectRatio(16f / 10f)
.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))

View File

@@ -7,13 +7,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
import hu.bbara.purefin.ui.model.LibraryUiModel
import hu.bbara.purefin.ui.model.MediaUiModel
@@ -49,9 +50,13 @@ fun LibraryPosterSection(
modifier = Modifier.fillMaxWidth(),
state = listState
) {
items(items = items, key = { item -> item.id }) { item ->
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
HomeBrowseCard(
uiModel = item,
sharedBoundsKey = homeMediaSharedBoundsKey(
origin = "library-${library.id}-$index",
mediaId = item.id
),
onMediaSelected = onMediaSelected
)
}

View File

@@ -24,17 +24,23 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.model.MediaUiModel
import hu.bbara.purefin.ui.common.image.PurefinAsyncImage
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsSource
import hu.bbara.purefin.ui.common.media.rememberHomeMediaSharedBoundsClick
@Composable
internal fun NextUpCard(
uiModel: MediaUiModel,
sharedBoundsKey: String,
onMediaSelected: (MediaUiModel) -> Unit,
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
val onClick = rememberHomeMediaSharedBoundsClick(sharedBoundsKey) {
onMediaSelected(uiModel)
}
Card(
onClick = { onMediaSelected(uiModel) },
onClick = onClick,
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = scheme.surfaceContainer),
modifier = modifier.width(256.dp)
@@ -42,6 +48,7 @@ internal fun NextUpCard(
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.homeMediaSharedBoundsSource(sharedBoundsKey)
.fillMaxWidth()
.aspectRatio(16f / 10f)
.background(scheme.surface)

View File

@@ -7,13 +7,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import hu.bbara.purefin.ui.common.header.SectionHeader
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsKey
import hu.bbara.purefin.ui.model.MediaUiModel
@Composable
@@ -42,9 +43,10 @@ fun NextUpSection(
modifier = Modifier.fillMaxWidth(),
state = listState
) {
items(items = items, key = { item -> item.id }) { item ->
itemsIndexed(items = items, key = { _, item -> item.id }) { index, item ->
NextUpCard(
uiModel = item,
sharedBoundsKey = homeMediaSharedBoundsKey("next-up-$index", item.id),
onMediaSelected = onMediaSelected
)
}

View File

@@ -39,6 +39,8 @@ import hu.bbara.purefin.model.Movie
import hu.bbara.purefin.navigation.MovieDto
import hu.bbara.purefin.ui.common.media.MediaHero
import hu.bbara.purefin.ui.common.media.MediaMetadataFlowRow
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsDestination
import hu.bbara.purefin.ui.common.media.isHomeMediaSharedBoundsTransitionActive
import hu.bbara.purefin.ui.screen.movie.components.MovieDetails
import hu.bbara.purefin.ui.screen.movie.components.MovieTopBar
import hu.bbara.purefin.ui.screen.waiting.PurefinWaitingScreen
@@ -94,15 +96,19 @@ private fun MovieScreenInternal(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
if (!isHomeMediaTransitionActive) {
MovieTopBar(
onBack = onBack,
modifier = Modifier
)
}
}
) { innerPadding ->
Column(
modifier = Modifier
@@ -110,6 +116,7 @@ private fun MovieScreenInternal(
.verticalScroll(rememberScrollState())
) {
MediaHeroSection(movie = movie)
if (!isHomeMediaTransitionActive) {
MovieDetails(
movie = movie,
downloadState = downloadState,
@@ -121,6 +128,7 @@ private fun MovieScreenInternal(
)
}
}
}
}
@Composable
@@ -134,6 +142,7 @@ fun MediaHeroSection(
Box (
modifier = modifier
.homeMediaSharedBoundsDestination()
.height(sectionHeight)
) {
MediaHero(

View File

@@ -41,6 +41,8 @@ import hu.bbara.purefin.model.Series
import hu.bbara.purefin.navigation.SeriesDto
import hu.bbara.purefin.ui.common.media.MediaHero
import hu.bbara.purefin.ui.common.media.MediaSynopsis
import hu.bbara.purefin.ui.common.media.homeMediaSharedBoundsDestination
import hu.bbara.purefin.ui.common.media.isHomeMediaSharedBoundsTransitionActive
import hu.bbara.purefin.ui.screen.series.components.CastRow
import hu.bbara.purefin.ui.screen.series.components.EpisodeCarousel
import hu.bbara.purefin.ui.screen.series.components.SeasonTabs
@@ -103,6 +105,7 @@ private fun SeriesScreenInternal(
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val isHomeMediaTransitionActive = isHomeMediaSharedBoundsTransitionActive()
fun getDefaultSeason() : Season {
for (season in series.seasons) {
@@ -131,11 +134,13 @@ private fun SeriesScreenInternal(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
if (!isHomeMediaTransitionActive) {
SeriesTopBar(
onBack = onBack,
modifier = Modifier
)
}
}
) { innerPadding ->
Column(
modifier = Modifier
@@ -143,6 +148,7 @@ private fun SeriesScreenInternal(
.verticalScroll(rememberScrollState())
) {
SeriesHeroSection(series = series)
if (!isHomeMediaTransitionActive) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -189,6 +195,7 @@ private fun SeriesScreenInternal(
}
}
}
}
}
@Composable
@@ -202,6 +209,7 @@ private fun SeriesHeroSection(
Box(
modifier = modifier
.homeMediaSharedBoundsDestination()
.fillMaxWidth()
.height(sectionHeight)
) {