feat(android): a poster library with pull to refresh

- titles are 2:3 posters three across on a phone, named under the poster
  instead of in a card body; cropped backdrops at full width fitted two
  titles to a screen
- a title's caption says "Film" or "1 episode" rather than "1 episodes",
  and wraps at large font scales instead of being cut to "2023 · 7"
- Continue watching cards carry the resume bar on the still and say how
  much is left: "S03E11 · 12 min left"
- search is a filled pill with a clear button
- pull to refresh crawls the shares, as the bar's refresh icon does
This commit is contained in:
nvrl committed 2026-10-03 04:58:35 +02:00
1 parent eee45bc8f8
commit 2fbbd0e090
7 files changed
+186 -104

No files matched your search

@@ -6,37 +6,45 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentProgress
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
/**
* How many shows the continue-watching shelf holds.
@@ -49,12 +57,14 @@ private const val CONTINUE_WATCHING_MAX = 12
/** One row of the continue-watching shelf: which episode, and where it sits. */
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun LibraryScreen(
state: AppUiState,
onSearch: (String) -> Unit,
onResume: (TitleRecord, Int) -> Unit,
onTitle: (TitleRecord) -> Unit,
onRefresh: () -> Unit,
padding: PaddingValues,
) {
// Most recently played first, one episode per show: a shelf that lists four
@@ -68,104 +78,175 @@ internal fun LibraryScreen(
?.let { Resumable(title, it.value, it.index) }
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
}
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
OutlinedTextField(
Column(Modifier.fillMaxSize().padding(padding)) {
// A filled pill rather than an outlined form field: search is how the
// page is navigated, not a value being entered.
TextField(
value = state.query,
onValueChange = onSearch,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true,
shape = CircleShape,
placeholder = { Text("Search library") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (state.query.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Close, contentDescription = "Clear search")
}
}
},
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
),
)
Spacer(Modifier.height(16.dp))
if (state.loading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
} else if (state.titles.isEmpty()) {
EmptyState("Your library is empty", "Add a Proton Drive public link under Shares, then refresh.")
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(180.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 24.dp),
) {
// Above the grid, and only when there is something in it: what a
// viewer opening the app wants is almost always the thing they
// were part way through, and finding it in an alphabetical grid
// is the long way round.
//
// Inside the grid rather than pinned over it, so the shelf
// scrolls away with the rest instead of holding a fixed slice of
// a phone screen for itself.
if (state.query.isBlank() && resumable.isNotEmpty()) {
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text("Continue watching", style = MaterialTheme.typography.titleMedium)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 12.dp),
) {
items(
resumable,
key = { "${it.episode.shareId}/${it.episode.linkId}" },
) { entry ->
Card(
Modifier.width(200.dp)
.clickable { onResume(entry.title, entry.index) },
) {
RemoteArtwork(
entry.episode.stillUrl ?: entry.title.backdropUrl,
entry.title.canonicalName ?: entry.title.name,
Modifier.fillMaxWidth().aspectRatio(16f / 9f),
fallback = entry.episode.thumbnailSource,
)
Column(Modifier.padding(10.dp)) {
Text(
entry.title.canonicalName ?: entry.title.name,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
entry.episode.label,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
entry.episode.progress?.let { progress ->
AccentProgress(
progress = {
progress.toFloat().coerceIn(0f, 1f)
},
modifier = Modifier.fillMaxWidth()
.padding(top = 8.dp),
)
}
}
}
}
}
}
}
}
items(state.titles, key = { it.key }) { title ->
Card(Modifier.fillMaxWidth().clickable { onTitle(title) }) {
RemoteArtwork(
title.backdropUrl ?: title.posterUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxWidth().height(210.dp),
fallback = title.thumbnailSource,
)
Column(Modifier.padding(12.dp)) {
Text(title.name, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(
listOfNotNull(title.year?.toString(), "${title.episodeCount} episodes").joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
)
}
}
// Pull to refresh, the gesture every list on a phone answers to; the
// icon in the bar stays for a viewer who does not know it.
PullToRefreshBox(
isRefreshing = state.refreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize(),
) {
when {
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.titles.isEmpty() -> EmptyState(
"Your library is empty",
"Add a Proton Drive public link under Shares, then refresh.",
)
else -> LibraryGrid(state, resumable, onResume, onTitle)
}
}
}
}
@Composable
private fun LibraryGrid(
state: AppUiState,
resumable: List<Resumable>,
onResume: (TitleRecord, Int) -> Unit,
onTitle: (TitleRecord) -> Unit,
) {
// Posters, three across on a phone. Cropped backdrops at full width fitted
// two titles to a screen, which is a library read one row at a time.
LazyVerticalGrid(
columns = GridCells.Adaptive(108.dp),
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
) {
// Above the grid, and only when there is something in it: what a viewer
// opening the app wants is almost always the thing they were part way
// through. Inside the grid rather than pinned over it, so the shelf
// scrolls away with the rest.
if (state.query.isBlank() && resumable.isNotEmpty()) {
item(key = "continue-watching", span = { GridItemSpan(maxLineSpan) }) {
Column {
Text("Continue watching", style = MaterialTheme.typography.titleMedium)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(top = 10.dp),
) {
items(resumable, key = { "${it.episode.shareId}/${it.episode.linkId}" }) { entry ->
ContinueCard(entry) { onResume(entry.title, entry.index) }
}
}
}
}
item(key = "library-heading", span = { GridItemSpan(maxLineSpan) }) {
Text("Library", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp))
}
}
items(state.titles, key = { it.key }) { title -> PosterTile(title) { onTitle(title) } }
}
}
/** A part-watched episode: its still, where it was left, and what is left of it. */
@Composable
private fun ContinueCard(entry: Resumable, onClick: () -> Unit) {
Column(Modifier.width(220.dp).clip(MaterialTheme.shapes.medium).clickable(onClick = onClick)) {
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).clip(MaterialTheme.shapes.medium)) {
RemoteArtwork(
entry.episode.stillUrl ?: entry.title.backdropUrl,
entry.title.canonicalName ?: entry.title.name,
Modifier.fillMaxSize(),
fallback = entry.episode.thumbnailSource,
)
entry.episode.progress?.let { progress ->
AccentProgress(
progress = { progress.toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter),
)
}
}
Text(
entry.title.canonicalName ?: entry.title.name,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 8.dp),
)
Text(
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/** One title: its poster, and its name and size under it rather than in a card. */
@Composable
private fun PosterTile(title: TitleRecord, onClick: () -> Unit) {
Column(Modifier.clip(MaterialTheme.shapes.medium).clickable(onClick = onClick)) {
RemoteArtwork(
title.posterUrl ?: title.backdropUrl,
title.canonicalName ?: title.name,
Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium),
fallback = title.thumbnailSource,
)
Spacer(Modifier.height(6.dp))
Text(
title.canonicalName ?: title.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
// Two lines, not one: at a large font scale "2023 · 7 episodes" does
// not fit a poster's width, and one line cut it to "2023 · 7".
Text(
title.caption(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
/** "2023 · 11 episodes", or "1988 · Film" — never "1 episodes". */
private fun TitleRecord.caption(): String = listOfNotNull(
(metadataYear ?: year)?.toString(),
when {
(metadataKind ?: kind) == TitleType.FILM && episodeCount == 1uL -> "Film"
episodeCount == 1uL -> "1 episode"
else -> "$episodeCount episodes"
},
).joinToString(" · ")
/**
* "12 min left", from where the episode was left and how far through that is.
* Null when either is unknown — a guess would be worse than no figure.
*/
private fun EpisodeRecord.timeLeft(): String? {
val at = resumeAt ?: return null
val fraction = progress?.takeIf { it > 0.0 && it < 1.0 } ?: return null
val minutes = ((at / fraction - at) / 60).toInt()
return if (minutes < 1) "under a minute left" else "$minutes min left"
}
@@ -201,6 +201,7 @@ fun ProtonStreamApp(
playerMinimized = false
},
{ selectedTitleKey = it.key },
model::refresh,
body,
)
} else {
@@ -59,13 +59,13 @@ class ScreenshotTest {
@Test
fun `the library on a phone`() = capture("library-phone") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues())
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
}
@Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `the library on a tablet`() = capture("library-tablet") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues())
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
}
@Test
@@ -74,13 +74,13 @@ class ScreenshotTest {
// reads it from, so the scale is set there.
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues())
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
}
}
@Test
fun `an empty library`() = capture("library-empty") {
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, PaddingValues())
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, PaddingValues())
}
@Test
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 64 KiB