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
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),
|
||||
// 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(),
|
||||
) {
|
||||
// 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.
|
||||
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(vertical = 12.dp),
|
||||
) {
|
||||
items(
|
||||
resumable,
|
||||
key = { "${it.episode.shareId}/${it.episode.linkId}" },
|
||||
) { entry ->
|
||||
Card(
|
||||
Modifier.width(200.dp)
|
||||
.clickable { onResume(entry.title, entry.index) },
|
||||
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.fillMaxWidth().aspectRatio(16f / 9f),
|
||||
Modifier.fillMaxSize(),
|
||||
fallback = entry.episode.thumbnailSource,
|
||||
)
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
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(
|
||||
entry.episode.label,
|
||||
listOfNotNull(entry.episode.label, entry.episode.timeLeft()).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
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) }) {
|
||||
|
||||
/** 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.backdropUrl ?: title.posterUrl,
|
||||
title.posterUrl ?: title.backdropUrl,
|
||||
title.canonicalName ?: title.name,
|
||||
Modifier.fillMaxWidth().height(210.dp),
|
||||
Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium),
|
||||
fallback = title.thumbnailSource,
|
||||
)
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(title.name, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
listOfNotNull(title.year?.toString(), "${title.episodeCount} episodes").joinToString(" · "),
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 64 KiB |