mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
9cba54df14
commit
630f07a462
5 files changed
+103
-10
No files matched your search
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user