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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.lazy.LazyRow 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.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Card import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.Icon
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search 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.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.narl.protonstream.ui.theme.AccentProgress import io.narl.protonstream.ui.theme.AccentProgress
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.SeasonRecord import uniffi.pstr_android.SeasonRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
/** /**
* How many shows the continue-watching shelf holds. * 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. */ /** 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) private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun LibraryScreen( internal fun LibraryScreen(
state: AppUiState, state: AppUiState,
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onResume: (TitleRecord, Int) -> Unit, onResume: (TitleRecord, Int) -> Unit,
onTitle: (TitleRecord) -> Unit, onTitle: (TitleRecord) -> Unit,
onRefresh: () -> Unit,
padding: PaddingValues, padding: PaddingValues,
) { ) {
// Most recently played first, one episode per show: a shelf that lists four // 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) } ?.let { Resumable(title, it.value, it.index) }
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX) }.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
} }
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) { Column(Modifier.fillMaxSize().padding(padding)) {
OutlinedTextField( // 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, value = state.query,
onValueChange = onSearch, onValueChange = onSearch,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true, singleLine = true,
shape = CircleShape,
placeholder = { Text("Search library") }, 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)) // Pull to refresh, the gesture every list on a phone answers to; the
if (state.loading) { // icon in the bar stays for a viewer who does not know it.
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } PullToRefreshBox(
} else if (state.titles.isEmpty()) { isRefreshing = state.refreshing,
EmptyState("Your library is empty", "Add a Proton Drive public link under Shares, then refresh.") onRefresh = onRefresh,
} else { modifier = Modifier.fillMaxSize(),
LazyVerticalGrid( ) {
columns = GridCells.Adaptive(180.dp), when {
horizontalArrangement = Arrangement.spacedBy(12.dp), state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
verticalArrangement = Arrangement.spacedBy(12.dp), CircularProgressIndicator()
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,
)
}
}
} }
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 playerMinimized = false
}, },
{ selectedTitleKey = it.key }, { selectedTitleKey = it.key },
model::refresh,
body, body,
) )
} else { } else {
@@ -59,13 +59,13 @@ class ScreenshotTest {
@Test @Test
fun `the library on a phone`() = capture("library-phone") { fun `the library on a phone`() = capture("library-phone") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues()) LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
} }
@Test @Test
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet) @Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
fun `the library on a tablet`() = capture("library-tablet") { fun `the library on a tablet`() = capture("library-tablet") {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues()) LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
} }
@Test @Test
@@ -74,13 +74,13 @@ class ScreenshotTest {
// reads it from, so the scale is set there. // reads it from, so the scale is set there.
val density = LocalDensity.current val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) { CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues()) LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
} }
} }
@Test @Test
fun `an empty library`() = capture("library-empty") { fun `an empty library`() = capture("library-empty") {
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, PaddingValues()) LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, PaddingValues())
} }
@Test @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