diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt index 65b8c37..9d15cce 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt @@ -954,6 +954,7 @@ private fun EpisodeChooser( episode.label, Modifier.fillMaxSize(), fallback = episode.thumbnailSource, + labelled = false, ) episode.progress?.takeIf { !episode.watched && !current }?.let { progress -> AccentProgress( @@ -1048,6 +1049,7 @@ private fun UpNextCard( upNext.label, Modifier.fillMaxSize(), fallback = upNext.thumbnailSource, + labelled = false, ) Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f))) CircularProgressIndicator( diff --git a/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt index 584f022..9c98c6b 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/HistoryScreen.kt @@ -130,6 +130,7 @@ private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, o episode.label, Modifier.fillMaxSize(), fallback = episode.thumbnailSource, + labelled = false, ) episode.progress?.takeIf { !episode.watched }?.let { progress -> AccentProgress( 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 a904908..e3b432d 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 @@ -44,7 +44,6 @@ 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 @@ -138,9 +137,7 @@ internal fun LibraryScreen( modifier = Modifier.fillMaxSize(), ) { when { - state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } + state.loading -> LibrarySkeleton() state.titles.isEmpty() -> EmptyState( "Your library is empty", "Add a Proton Drive public link under Shares, then refresh.", @@ -291,6 +288,35 @@ private fun LibraryGrid( } } +/** + * The grid's shape before the catalog has answered: posters and their two + * lines of text as blocks, so the page does not jump when the titles land. + */ +@Composable +private fun LibrarySkeleton() { + 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), + userScrollEnabled = false, + ) { + items(SKELETON_TILES) { + Column { + Skeleton(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(MaterialTheme.shapes.medium)) + Spacer(Modifier.height(8.dp)) + Skeleton(Modifier.fillMaxWidth(0.8f).height(12.dp).clip(MaterialTheme.shapes.small)) + Spacer(Modifier.height(6.dp)) + Skeleton(Modifier.fillMaxWidth(0.5f).height(10.dp).clip(MaterialTheme.shapes.small)) + } + } + } +} + +/** Enough placeholder tiles to fill a tablet's first screen. */ +private const val SKELETON_TILES = 18 + /** * The title the banner shows: the one watched last, or with nothing part * watched, one of the titles the provider has a backdrop for, turning over @@ -313,6 +339,7 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int) title.canonicalName ?: title.name, Modifier.fillMaxSize(), fallback = title.thumbnailSource, + labelled = false, ) Box( Modifier.fillMaxSize().background( diff --git a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt index cec3a87..1b545c0 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/RemoteArtwork.kt @@ -6,8 +6,13 @@ import android.graphics.BitmapFactory import android.util.LruCache import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -16,12 +21,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight import io.narl.protonstream.native.NativeRuntime import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -64,7 +76,9 @@ internal val EpisodeRecord.thumbnailSource: ThumbnailSource * * [fallback] is used only when there is no provider artwork or it fails to * load, which — with metadata off — is the common case rather than the rare - * one. + * one. Until either answers, the box pulses as a skeleton; when neither has + * anything, it shows [ArtworkPlaceholder] rather than an empty card — without + * initials when [labelled] is false, for art with the name already over it. */ @Composable internal fun RemoteArtwork( @@ -72,19 +86,98 @@ internal fun RemoteArtwork( description: String, modifier: Modifier = Modifier, fallback: ThumbnailSource? = null, + labelled: Boolean = true, ) { val context = LocalContext.current - var image by remember(url, fallback) { mutableStateOf(null) } - LaunchedEffect(url, fallback) { - image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap() + val enabled = LocalArtworkLoading.current + var image by remember(url, fallback) { mutableStateOf(ArtworkLoad.Pending) } + LaunchedEffect(url, fallback, enabled) { + if (!enabled) { + image = ArtworkLoad.Missing + return@LaunchedEffect + } + image = (loadArtwork(context, url) ?: loadThumbnail(fallback)) + ?.let { ArtworkLoad.Loaded(it.asImageBitmap()) } + ?: ArtworkLoad.Missing } - Box(modifier.background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center) { - image?.let { - Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) - } ?: Text("▶", style = MaterialTheme.typography.displaySmall) + when (val loaded = image) { + ArtworkLoad.Pending -> Skeleton(modifier) + ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled) + is ArtworkLoad.Loaded -> Image(loaded.image, description, modifier, contentScale = ContentScale.Crop) } } +private sealed interface ArtworkLoad { + data object Pending : ArtworkLoad + data object Missing : ArtworkLoad + data class Loaded(val image: ImageBitmap) : ArtworkLoad +} + +/** + * Whether [RemoteArtwork] fetches anything at all. + * + * Off in the screenshot tests: there is no bridge on the host, and whether a + * failed fetch lands before or after the capture would decide between a + * skeleton and a placeholder in the committed image. + */ +internal val LocalArtworkLoading = staticCompositionLocalOf { true } + +/** + * Something being loaded: the surface tint, breathing. + * + * A spinner per tile is a grid of spinners; a shape where the poster will be + * says what is coming and where, and settles the layout before it arrives. + */ +@Composable +internal fun Skeleton(modifier: Modifier = Modifier) { + val pulse by rememberInfiniteTransition(label = "skeleton").animateFloat( + initialValue = 0.55f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse), + label = "skeleton alpha", + ) + Box(modifier.graphicsLayer { alpha = pulse }.background(MaterialTheme.colorScheme.surfaceVariant)) +} + +/** + * Art for a title that has none: the accent as a gradient, and its initials. + * + * A black card with a play triangle on it said nothing about which title it + * was, so a grid of unmatched titles was a grid of identical cards. Each name + * sets where on the accent ramp its gradient starts, so neighbours differ + * while every one stays in the theme's colours. + */ +@Composable +internal fun ArtworkPlaceholder(name: String, modifier: Modifier = Modifier, labelled: Boolean = true) { + val scheme = MaterialTheme.colorScheme + val brush = remember(name, scheme.primary, scheme.secondary) { + val start = lerp(scheme.primary, scheme.secondary, (name.hashCode().mod(5)) / 4f) + Brush.linearGradient(listOf(start, lerp(start, Color.Black, 0.55f))) + } + BoxWithConstraints(modifier.background(brush), contentAlignment = Alignment.Center) { + if (!labelled) return@BoxWithConstraints + val size = with(LocalDensity.current) { (minOf(maxWidth, maxHeight) * 0.3f).toSp() } + Text( + initials(name), + color = Color.White.copy(alpha = 0.9f), + fontSize = size, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + } +} + +/** + * "Oshi no Ko" is "OK", "Akira" is "A": the first letters of up to two + * capitalised words, so particles and articles in a romanised name do not + * take a slot. A name with no capitals uses its first two words. + */ +internal fun initials(name: String): String { + val words = name.split(' ', '-', '_', '.', ':').filter { word -> word.firstOrNull()?.isLetterOrDigit() == true } + val picked = words.filter { it.first().isUpperCase() || it.first().isDigit() }.ifEmpty { words } + return picked.take(2).joinToString("") { it.first().uppercase() } +} + /** * The same HTTPS-only fetch behind the library grid, reusable off the composition. * diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index a777139..f98618b 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -131,6 +131,7 @@ internal fun TitleScreen( title.canonicalName ?: title.name, Modifier.fillMaxSize(), fallback = title.thumbnailSource, + labelled = false, ) Box( Modifier.fillMaxSize().background( @@ -386,6 +387,7 @@ private fun EpisodeRow( heading, Modifier.fillMaxSize(), fallback = episode.thumbnailSource, + labelled = false, ) episode.progress?.takeIf { !episode.watched }?.let { progress -> AccentProgress( diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ArtworkTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ArtworkTest.kt new file mode 100644 index 0000000..26779fd --- /dev/null +++ b/android/app/src/test/java/io/narl/protonstream/ui/ArtworkTest.kt @@ -0,0 +1,28 @@ +package io.narl.protonstream.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ArtworkTest { + @Test + fun `initials skip the lower-case particles of a romanised name`() { + assertEquals("OK", initials("Oshi no Ko")) + assertEquals("AH", initials("Ano Hi Mita Hana no Namae o Bokutachi wa Mada Shiranai.")) + } + + @Test + fun `a one-word name has one initial`() { + assertEquals("A", initials("Akira")) + } + + @Test + fun `a name with no capitals falls back to its first two words`() { + assertEquals("BW", initials("blue world order")) + } + + @Test + fun `punctuation and separators are not words`() { + assertEquals("S2", initials("Steins;Gate - 2")) + assertEquals("", initials(" - ")) + } +} 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 0928476..ee176e9 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 @@ -36,8 +36,9 @@ import uniffi.pstr_android.TitleType * The screens are about to be redesigned one by one, and a layout regression — * a row that wraps, a button pushed off the edge — compiles and passes every * other test. Here it is a changed PNG in review. No artwork is loaded: the - * bridge is not available on the host, so every tile shows its placeholder, - * which is also what a title without metadata looks like on a phone. + * bridge is not available on the host, so every tile shows its generated + * placeholder, which is also what a title without metadata looks like on a + * phone. */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -53,7 +54,9 @@ class ScreenshotTest { private fun capture(name: String, content: @Composable () -> Unit) { compose.setContent { ProtonStreamTheme { - Surface(color = MaterialTheme.colorScheme.background) { content() } + CompositionLocalProvider(LocalArtworkLoading provides false) { + Surface(color = MaterialTheme.colorScheme.background) { content() } + } } } compose.onRoot().captureRoboImage("src/test/screenshots/$name.png") @@ -85,6 +88,11 @@ class ScreenshotTest { LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues()) } + @Test + fun `the library while the catalog loads`() = capture("library-loading") { + LibraryScreen(AppUiState(), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues()) + } + @Test fun `a title with seasons`() = capture("title-phone") { TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues()) diff --git a/android/app/src/test/screenshots/history-phone.png b/android/app/src/test/screenshots/history-phone.png index 85d324e..90c4ea8 100644 Binary files a/android/app/src/test/screenshots/history-phone.png and b/android/app/src/test/screenshots/history-phone.png differ diff --git a/android/app/src/test/screenshots/library-loading.png b/android/app/src/test/screenshots/library-loading.png new file mode 100644 index 0000000..c96bf5c Binary files /dev/null and b/android/app/src/test/screenshots/library-loading.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 a1dfe71..ef85633 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 f6e4ff0..a800890 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 c19a15f..c802f33 100644 Binary files a/android/app/src/test/screenshots/library-tablet.png and b/android/app/src/test/screenshots/library-tablet.png differ diff --git a/android/app/src/test/screenshots/title-phone.png b/android/app/src/test/screenshots/title-phone.png index e9ec364..bcc5b4c 100644 Binary files a/android/app/src/test/screenshots/title-phone.png and b/android/app/src/test/screenshots/title-phone.png differ