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.
This commit is contained in:
nvrl committed 2026-10-03 13:34:04 +02:00
1 parent 9cba54df14
commit 630f07a462
5 files changed
+131 -38

No files matched your search

+1
View File
@@ -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"
@@ -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<SharedTransitionScope?> { null }
internal val LocalPaneVisibility = staticCompositionLocalOf<AnimatedVisibilityScope?> { 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) {
@@ -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))
@@ -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,
@@ -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,