diff --git a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt index 88b3047..4023802 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/LibraryScreen.kt @@ -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, + 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" +} diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index 79c1817..35d5178 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -201,6 +201,7 @@ fun ProtonStreamApp( playerMinimized = false }, { selectedTitleKey = it.key }, + model::refresh, body, ) } else { diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt index ea332d8..a95cda1 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -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 diff --git a/android/app/src/test/screenshots/library-empty.png b/android/app/src/test/screenshots/library-empty.png index 8d0f57e..c46d27e 100644 Binary files a/android/app/src/test/screenshots/library-empty.png and b/android/app/src/test/screenshots/library-empty.png differ diff --git a/android/app/src/test/screenshots/library-phone-large-font.png b/android/app/src/test/screenshots/library-phone-large-font.png index cd0ff4b..4173ce2 100644 Binary files a/android/app/src/test/screenshots/library-phone-large-font.png and b/android/app/src/test/screenshots/library-phone-large-font.png differ diff --git a/android/app/src/test/screenshots/library-phone.png b/android/app/src/test/screenshots/library-phone.png index 1e175b2..8041d5d 100644 Binary files a/android/app/src/test/screenshots/library-phone.png and b/android/app/src/test/screenshots/library-phone.png differ diff --git a/android/app/src/test/screenshots/library-tablet.png b/android/app/src/test/screenshots/library-tablet.png index 7fee3bb..39a77dd 100644 Binary files a/android/app/src/test/screenshots/library-tablet.png and b/android/app/src/test/screenshots/library-tablet.png differ