From 630f07a462b44ada7aa6dd28585ee5b3db8c5ba7 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 3 Oct 2026 13:34:04 +0200 Subject: [PATCH] feat(android): library and title side by side on tablets, a poster that becomes the page, and predictive back From 840 dp wide, the library and the open title share the window, so moving between titles is one tap and the grid stays where it was; with nothing open, the right pane says to choose one. On narrower windows, a poster tapped in the grid grows into the title page's backdrop and shrinks back on the way out. The app opts into predictive back. Dragging back on a title page shrinks the page as the gesture goes and restores it if the gesture is abandoned. --- android/app/src/main/AndroidManifest.xml | 1 + .../java/io/narl/protonstream/ui/Common.kt | 29 +++++ .../io/narl/protonstream/ui/LibraryScreen.kt | 2 +- .../narl/protonstream/ui/ProtonStreamApp.kt | 102 ++++++++++++------ .../io/narl/protonstream/ui/TitleScreen.kt | 35 +++++- 5 files changed, 131 insertions(+), 38 deletions(-) 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,