feat(android): generated placeholder art and a skeleton library
A title without artwork shows the accent as a gradient with its initials instead of a black card with a play triangle, so a library of unmatched titles is no longer a grid of identical cards. Art with the name already over it, and episode stills, get the gradient without initials. While artwork loads, and while the catalog first loads, the page shows pulsing shapes where the posters will be rather than a spinner. The screenshot tests turn artwork loading off, so whether a failed fetch lands before the capture no longer decides what the image shows.
No files matched your search
@@ -954,6 +954,7 @@ private fun EpisodeChooser(
|
|||||||
episode.label,
|
episode.label,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = episode.thumbnailSource,
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
episode.progress?.takeIf { !episode.watched && !current }?.let { progress ->
|
episode.progress?.takeIf { !episode.watched && !current }?.let { progress ->
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
@@ -1048,6 +1049,7 @@ private fun UpNextCard(
|
|||||||
upNext.label,
|
upNext.label,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = upNext.thumbnailSource,
|
fallback = upNext.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
|
Box(Modifier.size(40.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.5f)))
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ private fun HistoryRow(entry: Played, onPlay: () -> Unit, onTitle: () -> Unit, o
|
|||||||
episode.label,
|
episode.label,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = episode.thumbnailSource,
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
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.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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -138,9 +137,7 @@ internal fun LibraryScreen(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
state.loading -> LibrarySkeleton()
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
state.titles.isEmpty() -> EmptyState(
|
state.titles.isEmpty() -> EmptyState(
|
||||||
"Your library is empty",
|
"Your library is empty",
|
||||||
"Add a Proton Drive public link under Shares, then refresh.",
|
"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
|
* 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
|
* 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,
|
title.canonicalName ?: title.name,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = title.thumbnailSource,
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ import android.graphics.BitmapFactory
|
|||||||
import android.util.LruCache
|
import android.util.LruCache
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
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.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -16,12 +21,19 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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 io.narl.protonstream.native.NativeRuntime
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
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
|
* [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
|
* 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
|
@Composable
|
||||||
internal fun RemoteArtwork(
|
internal fun RemoteArtwork(
|
||||||
@@ -72,19 +86,98 @@ internal fun RemoteArtwork(
|
|||||||
description: String,
|
description: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
fallback: ThumbnailSource? = null,
|
fallback: ThumbnailSource? = null,
|
||||||
|
labelled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var image by remember(url, fallback) { mutableStateOf<ImageBitmap?>(null) }
|
val enabled = LocalArtworkLoading.current
|
||||||
LaunchedEffect(url, fallback) {
|
var image by remember(url, fallback) { mutableStateOf<ArtworkLoad>(ArtworkLoad.Pending) }
|
||||||
image = (loadArtwork(context, url) ?: loadThumbnail(fallback))?.asImageBitmap()
|
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) {
|
when (val loaded = image) {
|
||||||
image?.let {
|
ArtworkLoad.Pending -> Skeleton(modifier)
|
||||||
Image(it, description, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
ArtworkLoad.Missing -> ArtworkPlaceholder(description, modifier, labelled)
|
||||||
} ?: Text("▶", style = MaterialTheme.typography.displaySmall)
|
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.
|
* The same HTTPS-only fetch behind the library grid, reusable off the composition.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ internal fun TitleScreen(
|
|||||||
title.canonicalName ?: title.name,
|
title.canonicalName ?: title.name,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = title.thumbnailSource,
|
fallback = title.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
@@ -386,6 +387,7 @@ private fun EpisodeRow(
|
|||||||
heading,
|
heading,
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
fallback = episode.thumbnailSource,
|
fallback = episode.thumbnailSource,
|
||||||
|
labelled = false,
|
||||||
)
|
)
|
||||||
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
episode.progress?.takeIf { !episode.watched }?.let { progress ->
|
||||||
AccentProgress(
|
AccentProgress(
|
||||||
|
|||||||
@@ -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(" - "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,8 +36,9 @@ import uniffi.pstr_android.TitleType
|
|||||||
* The screens are about to be redesigned one by one, and a layout regression —
|
* 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
|
* 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
|
* 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,
|
* bridge is not available on the host, so every tile shows its generated
|
||||||
* which is also what a title without metadata looks like on a phone.
|
* placeholder, which is also what a title without metadata looks like on a
|
||||||
|
* phone.
|
||||||
*/
|
*/
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
@@ -53,7 +54,9 @@ class ScreenshotTest {
|
|||||||
private fun capture(name: String, content: @Composable () -> Unit) {
|
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
ProtonStreamTheme {
|
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")
|
compose.onRoot().captureRoboImage("src/test/screenshots/$name.png")
|
||||||
@@ -85,6 +88,11 @@ class ScreenshotTest {
|
|||||||
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the library while the catalog loads`() = capture("library-loading") {
|
||||||
|
LibraryScreen(AppUiState(), {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a title with seasons`() = capture("title-phone") {
|
fun `a title with seasons`() = capture("title-phone") {
|
||||||
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 854 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 850 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 649 KiB |