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
+103 -10

No files matched your search

+1
View File
@@ -11,6 +11,7 @@
android:name=".ProtonStreamApplication" android:name=".ProtonStreamApplication"
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
@@ -1,5 +1,8 @@
package io.narl.protonstream.ui 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -12,6 +15,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -62,6 +66,31 @@ internal fun pageBarColors(): TopAppBarColors = TopAppBarDefaults.topAppBarColor
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, 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 @Composable
internal fun EmptyState(title: String, body: String) { internal fun EmptyState(title: String, body: String) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -520,7 +520,7 @@ private fun PosterTile(title: TitleRecord, onClick: () -> Unit, onLongClick: ()
RemoteArtwork( RemoteArtwork(
title.posterUrl ?: title.backdropUrl, title.posterUrl ?: title.backdropUrl,
title.canonicalName ?: title.name, 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, fallback = title.thumbnailSource,
) )
Spacer(Modifier.height(6.dp)) Spacer(Modifier.height(6.dp))
@@ -2,8 +2,13 @@ package io.narl.protonstream.ui
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent 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.clickable
import androidx.compose.foundation.layout.Box 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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.NativeMpvHost
import io.narl.protonstream.playback.PlayerScreen 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. */ /** How much room the floating mini transport needs at the foot of a page. */
private val MINI_TRANSPORT_INSET = 88.dp 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), Settings("Settings", Icons.Default.Settings),
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun ProtonStreamApp( fun ProtonStreamApp(
playerHost: LibmpvHost? = null, playerHost: LibmpvHost? = null,
@@ -187,7 +195,11 @@ fun ProtonStreamApp(
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
AnimatedContent(destination, label = "primary navigation") { target -> AnimatedContent(destination, label = "primary navigation") { target ->
when (target) { when (target) {
Destination.Library -> if (selectedTitle == null) { // 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( LibraryScreen(
state, state,
model::search, model::search,
@@ -204,12 +216,13 @@ fun ProtonStreamApp(
onMatchChanged = model::reloadAfterMetadataChange, onMatchChanged = model::reloadAfterMetadataChange,
onError = model::reportError, onError = model::reportError,
) )
} else { }
val titlePane = @Composable { selected: TitleRecord ->
TitleScreen( TitleScreen(
selectedTitle, selected,
playerHost != null, playerHost != null,
{ _, index -> { _, index ->
playingTitleKey = selectedTitle.key playingTitleKey = selected.key
playingIndex = index playingIndex = index
playerMinimized = false playerMinimized = false
}, },
@@ -220,6 +233,29 @@ fun ProtonStreamApp(
body, 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( Destination.Shares -> SharesScreen(
state.shares, state.shares,
model::addShare, model::addShare,
@@ -1,7 +1,11 @@
package io.narl.protonstream.ui package io.narl.protonstream.ui
import android.content.Intent 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.clickable
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -96,7 +100,19 @@ internal fun TitleScreen(
var showMatch by remember(title.key) { mutableStateOf(false) } var showMatch by remember(title.key) { mutableStateOf(false) }
var overviewOpen 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 // Display order across seasons: what previous/next and autoplay walk, and
// the same order the desktop client uses. // 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 // 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 // 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. // 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 side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f) val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
LazyColumn( LazyColumn(
@@ -135,7 +162,7 @@ internal fun TitleScreen(
// Edge to edge, fading into the page: the art is the first thing // 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 // the page says, and a framed thumbnail with a margin round it read
// as one card among the many below it. // as one card among the many below it.
Box(Modifier.fillMaxWidth().height(backdropHeight)) { Box(Modifier.then(sharedArt(title.key)).fillMaxWidth().height(backdropHeight)) {
RemoteArtwork( RemoteArtwork(
title.backdropUrl ?: title.posterUrl, title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name, title.canonicalName ?: title.name,