diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cd30252..f1deeec 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ android:name=".ProtonStreamApplication" android:allowBackup="false" android:dataExtractionRules="@xml/data_extraction_rules" + android:enableOnBackInvokedCallback="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" diff --git a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt index 83e6012..51855c7 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/Common.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/Common.kt @@ -1,5 +1,8 @@ package io.narl.protonstream.ui +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -12,6 +15,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -62,6 +66,31 @@ internal fun pageBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColor scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, ) +/** + * The shared-element scopes the library-to-title switch runs in, or null where + * there is none — the two-pane layout, a screenshot, a preview. Locals rather + * than parameters, because the poster that animates is three calls deep in the + * grid and nothing between needs to know. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +internal val LocalSharedTransition = staticCompositionLocalOf { null } +internal val LocalPaneVisibility = staticCompositionLocalOf { null } + +/** + * A title's art as one element across the switch: the poster tapped in the + * grid grows into the backdrop of the page it opens, and shrinks back on the + * way out, so the page is visibly *that* title's rather than a new screen. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun sharedArt(key: String): Modifier { + val transition = LocalSharedTransition.current ?: return Modifier + val visibility = LocalPaneVisibility.current ?: return Modifier + return with(transition) { + Modifier.sharedBounds(rememberSharedContentState("art/$key"), visibility) + } +} + @Composable internal fun EmptyState(title: String, body: String) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index 6e3cd21..39b4ff1 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -520,7 +520,7 @@ private fun PosterTile(title: TitleRecord, onClick: () -> Unit, onLongClick: () RemoteArtwork( title.posterUrl ?: title.backdropUrl, title.canonicalName ?: title.name, - Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium), + Modifier.then(sharedArt(title.key)).fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium), fallback = title.thumbnailSource, ) Spacer(Modifier.height(6.dp)) diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 83af747..423c8c8 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -2,8 +2,13 @@ package io.narl.protonstream.ui import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.material3.VerticalDivider import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -68,6 +73,9 @@ import io.narl.protonstream.playback.LibmpvHost import io.narl.protonstream.playback.NativeMpvHost import io.narl.protonstream.playback.PlayerScreen +/** From this width the library and an open title share the window. */ +private val TWO_PANE_WIDTH = 840.dp + /** How much room the floating mini transport needs at the foot of a page. */ private val MINI_TRANSPORT_INSET = 88.dp @@ -86,7 +94,7 @@ private enum class Destination(val label: String, val icon: ImageVector) { Settings("Settings", Icons.Default.Settings), } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @Composable fun ProtonStreamApp( playerHost: LibmpvHost? = null, @@ -187,38 +195,66 @@ fun ProtonStreamApp( Box(Modifier.fillMaxSize()) { AnimatedContent(destination, label = "primary navigation") { target -> when (target) { - Destination.Library -> if (selectedTitle == null) { - LibraryScreen( - state, - model::search, - { title, index -> - playingTitleKey = title.key - playingIndex = index - playerMinimized = false - }, - { selectedTitleKey = it.key }, - model::refresh, - model::setTitleWatched, - model::forgetPosition, - body, - onMatchChanged = model::reloadAfterMetadataChange, - onError = model::reportError, - ) - } else { - TitleScreen( - selectedTitle, - playerHost != null, - { _, index -> - playingTitleKey = selectedTitle.key - playingIndex = index - playerMinimized = false - }, - { selectedTitleKey = null }, - model::reportError, - model::reloadAfterMetadataChange, - model::setWatched, - body, - ) + // Side by side where there is room for both: on a tablet the + // library stays in view while a title is open, so moving + // between titles is one tap rather than back and in again. + Destination.Library -> BoxWithConstraints(Modifier.fillMaxSize()) { + val libraryPane = @Composable { + LibraryScreen( + state, + model::search, + { title, index -> + playingTitleKey = title.key + playingIndex = index + playerMinimized = false + }, + { selectedTitleKey = it.key }, + model::refresh, + model::setTitleWatched, + model::forgetPosition, + body, + onMatchChanged = model::reloadAfterMetadataChange, + onError = model::reportError, + ) + } + val titlePane = @Composable { selected: TitleRecord -> + TitleScreen( + selected, + playerHost != null, + { _, index -> + playingTitleKey = selected.key + playingIndex = index + playerMinimized = false + }, + { selectedTitleKey = null }, + model::reportError, + model::reloadAfterMetadataChange, + model::setWatched, + body, + ) + } + if (maxWidth >= TWO_PANE_WIDTH) { + Row(Modifier.fillMaxSize()) { + Box(Modifier.weight(0.45f)) { libraryPane() } + VerticalDivider() + Box(Modifier.weight(0.55f)) { + selectedTitle?.let { titlePane(it) } ?: Box(Modifier.padding(body)) { + EmptyState("Choose a title", "Its seasons and episodes open here.") + } + } + } + } else { + SharedTransitionLayout { + AnimatedContent(selectedTitle, contentKey = { it?.key }, label = "title") { open -> + CompositionLocalProvider( + LocalSharedTransition provides this@SharedTransitionLayout, + LocalPaneVisibility provides this, + ) { + open?.let { titlePane(it) } ?: libraryPane() + } + } + } + } } Destination.Shares -> SharesScreen( state.shares, diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index 2d39590..3e45cd7 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -1,7 +1,11 @@ package io.narl.protonstream.ui import android.content.Intent -import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.graphics.graphicsLayer +import kotlin.coroutines.cancellation.CancellationException import androidx.compose.foundation.clickable import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -96,7 +100,19 @@ internal fun TitleScreen( var showMatch by remember(title.key) { mutableStateOf(false) } var overviewOpen by remember(title.key) { mutableStateOf(false) } - BackHandler(onBack = onBack) + // Predictive back: the page shrinks towards the library as the gesture + // is dragged, and springs back if it is let go of, so the viewer sees + // where back goes before committing to it. + var backProgress by remember { mutableFloatStateOf(0f) } + PredictiveBackHandler { gesture -> + try { + gesture.collect { backProgress = it.progress } + onBack() + } catch (cancelled: CancellationException) { + backProgress = 0f + throw cancelled + } + } // Display order across seasons: what previous/next and autoplay walk, and // the same order the desktop client uses. @@ -123,7 +139,18 @@ internal fun TitleScreen( // button and synopsis lines two thousand pixels wide are read by turning // the head. The backdrop still spans the window, but no taller than about // half of it, or a landscape tablet opened on a picture and nothing else. - BoxWithConstraints(Modifier.fillMaxSize().padding(padding)) { + BoxWithConstraints( + Modifier + .fillMaxSize() + .graphicsLayer { + val scale = 1f - 0.1f * backProgress + scaleX = scale + scaleY = scale + shape = RoundedCornerShape((32 * backProgress).dp) + clip = backProgress > 0f + } + .padding(padding), + ) { val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp) val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f) LazyColumn( @@ -135,7 +162,7 @@ internal fun TitleScreen( // Edge to edge, fading into the page: the art is the first thing // the page says, and a framed thumbnail with a margin round it read // as one card among the many below it. - Box(Modifier.fillMaxWidth().height(backdropHeight)) { + Box(Modifier.then(sharedArt(title.key)).fillMaxWidth().height(backdropHeight)) { RemoteArtwork( title.backdropUrl ?: title.posterUrl, title.canonicalName ?: title.name,