mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(android): light-palette and tablet screenshots, and a title page that fits a tablet
The screenshot tests now also capture the library and a title in Catppuccin Latte, and a title and the history page on a tablet. The tablet capture showed the title page opening on nothing but its backdrop: a 16:9 picture the width of a landscape tablet is taller than the window. The backdrop is now no taller than about half the window, and the rest of the page keeps a phone's reading width, centred.
This commit is contained in:
1 parent
4ff8ae303c
commit
9cba54df14
6 files changed
+224
-156
No files matched your search
@@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -117,173 +119,183 @@ internal fun TitleScreen(
|
||||
.getWorkInfosByTagLiveData(DownloadCoordinator.TAG).observeAsState(emptyList())
|
||||
val downloads = remember(work) { DownloadStateStore(context).records() }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item(key = "backdrop") {
|
||||
// Edge to edge, fading into the page: the art is the first thing
|
||||
// the page says, and a framed thumbnail with a margin round it read
|
||||
// as one card among the many below it.
|
||||
Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f)) {
|
||||
RemoteArtwork(
|
||||
title.backdropUrl ?: title.posterUrl,
|
||||
title.canonicalName ?: title.name,
|
||||
Modifier.fillMaxSize(),
|
||||
fallback = title.thumbnailSource,
|
||||
labelled = false,
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0.45f to Color.Transparent,
|
||||
1f to MaterialTheme.colorScheme.background,
|
||||
// On a tablet the page keeps a phone's reading width, centred: a Play
|
||||
// button and synopsis lines two thousand pixels wide are read by turning
|
||||
// the head. The backdrop still spans the window, but no taller than about
|
||||
// half of it, or a landscape tablet opened on a picture and nothing else.
|
||||
BoxWithConstraints(Modifier.fillMaxSize().padding(padding)) {
|
||||
val side = ((maxWidth - READABLE_WIDTH) / 2).coerceAtLeast(0.dp)
|
||||
val backdropHeight = minOf(maxWidth * 9f / 16f, maxHeight * 0.55f)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item(key = "backdrop") {
|
||||
// Edge to edge, fading into the page: the art is the first thing
|
||||
// the page says, and a framed thumbnail with a margin round it read
|
||||
// as one card among the many below it.
|
||||
Box(Modifier.fillMaxWidth().height(backdropHeight)) {
|
||||
RemoteArtwork(
|
||||
title.backdropUrl ?: title.posterUrl,
|
||||
title.canonicalName ?: title.name,
|
||||
Modifier.fillMaxSize(),
|
||||
fallback = title.thumbnailSource,
|
||||
labelled = false,
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0.45f to Color.Transparent,
|
||||
1f to MaterialTheme.colorScheme.background,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.padding(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.4f)),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "heading") {
|
||||
Column(Modifier.padding(horizontal = 16.dp)) {
|
||||
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium)
|
||||
title.originalName?.takeIf { it != title.canonicalName }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Text(
|
||||
listOfNotNull(
|
||||
(title.metadataYear ?: title.year)?.toString(),
|
||||
title.rating?.let { "★ %.1f".format(it) },
|
||||
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
|
||||
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
// The one thing the page is for, full width and naming what it
|
||||
// will play: "Resume" alone left the viewer guessing which
|
||||
// episode of forty it meant.
|
||||
AccentButton(
|
||||
onClick = { onPlay(playlist, nextUp) },
|
||||
enabled = playerReady && playlist.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
when {
|
||||
!playerReady -> "Player loading…"
|
||||
upNext == null -> "Play"
|
||||
upNext.resumeAt != null -> "Resume ${upNext.label}"
|
||||
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
|
||||
playlist.size == 1 -> "Play"
|
||||
else -> "Play ${upNext.label}"
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// Everything else as labelled icons in one row: four actions of
|
||||
// three button styles used to wrap over three lines and bury
|
||||
// the episodes under them.
|
||||
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
// Only where it would do something different: an unstarted
|
||||
// show already starts at the beginning.
|
||||
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
|
||||
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
|
||||
onPlay(playlist, 0)
|
||||
}
|
||||
}
|
||||
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
|
||||
DownloadCoordinator.enqueue(context, playlist)
|
||||
}
|
||||
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
|
||||
// The provider's own page for this title: where a viewer goes
|
||||
// to check that the thing the app matched is what they have.
|
||||
title.externalUrl?.let { url ->
|
||||
TitleAction(
|
||||
Icons.AutoMirrored.Filled.OpenInNew,
|
||||
title.metadataProvider?.displayName() ?: "Provider",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
|
||||
}.onFailure(onPreferenceError)
|
||||
}
|
||||
}
|
||||
}
|
||||
title.overview?.let { overview ->
|
||||
// Three lines, then a tap for the rest: a synopsis with its
|
||||
// source notes ran to a screen and a half on a phone.
|
||||
Text(
|
||||
overview.trim(),
|
||||
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
IconButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.clickable { overviewOpen = !overviewOpen },
|
||||
)
|
||||
}
|
||||
// Said plainly, because it changes what a re-match will do: a
|
||||
// hand-picked match is not overwritten by an automatic pass.
|
||||
if (title.manualMatch) {
|
||||
Text(
|
||||
"Matched by hand",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
.statusBarsPadding()
|
||||
.padding(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.4f)),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to library", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item(key = "seasons") {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
|
||||
FilterChip(
|
||||
selected = index == seasonIndex,
|
||||
onClick = { seasonIndex = index },
|
||||
label = { Text(entry.label) },
|
||||
item(key = "heading") {
|
||||
Column(Modifier.padding(horizontal = side + 16.dp)) {
|
||||
Text(title.canonicalName ?: title.name, style = MaterialTheme.typography.headlineMedium)
|
||||
title.originalName?.takeIf { it != title.canonicalName }?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Text(
|
||||
listOfNotNull(
|
||||
(title.metadataYear ?: title.year)?.toString(),
|
||||
title.rating?.let { "★ %.1f".format(it) },
|
||||
title.genres.takeIf { it.isNotEmpty() }?.take(3)?.joinToString(", "),
|
||||
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
// The one thing the page is for, full width and naming what it
|
||||
// will play: "Resume" alone left the viewer guessing which
|
||||
// episode of forty it meant.
|
||||
AccentButton(
|
||||
onClick = { onPlay(playlist, nextUp) },
|
||||
enabled = playerReady && playlist.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
when {
|
||||
!playerReady -> "Player loading…"
|
||||
upNext == null -> "Play"
|
||||
upNext.resumeAt != null -> "Resume ${upNext.label}"
|
||||
title.watchedCount > 0uL -> "Continue with ${upNext.label}"
|
||||
playlist.size == 1 -> "Play"
|
||||
else -> "Play ${upNext.label}"
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// Everything else as labelled icons in one row: four actions of
|
||||
// three button styles used to wrap over three lines and bury
|
||||
// the episodes under them.
|
||||
Row(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
// Only where it would do something different: an unstarted
|
||||
// show already starts at the beginning.
|
||||
if (upNext?.resumeAt != null || title.watchedCount > 0uL) {
|
||||
TitleAction(Icons.Default.Replay, "Start over", Modifier.weight(1f), enabled = playerReady) {
|
||||
onPlay(playlist, 0)
|
||||
}
|
||||
}
|
||||
TitleAction(Icons.Default.Download, "Download", Modifier.weight(1f)) {
|
||||
DownloadCoordinator.enqueue(context, playlist)
|
||||
}
|
||||
TitleAction(Icons.Default.Edit, "Match", Modifier.weight(1f)) { showMatch = true }
|
||||
// The provider's own page for this title: where a viewer goes
|
||||
// to check that the thing the app matched is what they have.
|
||||
title.externalUrl?.let { url ->
|
||||
TitleAction(
|
||||
Icons.AutoMirrored.Filled.OpenInNew,
|
||||
title.metadataProvider?.displayName() ?: "Provider",
|
||||
Modifier.weight(1f),
|
||||
) {
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
|
||||
}.onFailure(onPreferenceError)
|
||||
}
|
||||
}
|
||||
}
|
||||
title.overview?.let { overview ->
|
||||
// Three lines, then a tap for the rest: a synopsis with its
|
||||
// source notes ran to a screen and a half on a phone.
|
||||
Text(
|
||||
overview.trim(),
|
||||
maxLines = if (overviewOpen) Int.MAX_VALUE else 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(top = 12.dp)
|
||||
.clickable { overviewOpen = !overviewOpen },
|
||||
)
|
||||
}
|
||||
// Said plainly, because it changes what a re-match will do: a
|
||||
// hand-picked match is not overwritten by an automatic pass.
|
||||
if (title.manualMatch) {
|
||||
Text(
|
||||
"Matched by hand",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
season?.let {
|
||||
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
|
||||
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
|
||||
}
|
||||
item(key = "seasons") {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(start = side + 16.dp, end = side + 4.dp, top = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
LazyRow(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
itemsIndexed(title.seasons, key = { _, it -> it.label }) { index, entry ->
|
||||
FilterChip(
|
||||
selected = index == seasonIndex,
|
||||
onClick = { seasonIndex = index },
|
||||
label = { Text(entry.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
season?.let {
|
||||
IconButton(onClick = { DownloadCoordinator.enqueue(context, it.episodes) }) {
|
||||
Icon(Icons.Default.Download, contentDescription = "Download ${it.label}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
season?.let { shown ->
|
||||
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
|
||||
EpisodeRow(
|
||||
episode = episode,
|
||||
numbering = episode.numbering(shown.number, position),
|
||||
position = position,
|
||||
download = downloads.firstOrNull {
|
||||
it.shareId == episode.shareId && it.linkId == episode.linkId
|
||||
},
|
||||
playerReady = playerReady,
|
||||
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
|
||||
onDownload = { DownloadCoordinator.enqueue(context, episode) },
|
||||
onPause = { DownloadCoordinator.pause(context, it) },
|
||||
onResume = { DownloadCoordinator.resume(context, it) },
|
||||
onSetWatched = { onSetWatched(episode, it) },
|
||||
)
|
||||
season?.let { shown ->
|
||||
itemsIndexed(shown.episodes, key = { _, it -> it.linkId }) { position, episode ->
|
||||
Box(Modifier.padding(horizontal = side)) {
|
||||
EpisodeRow(
|
||||
episode = episode,
|
||||
numbering = episode.numbering(shown.number, position),
|
||||
position = position,
|
||||
download = downloads.firstOrNull {
|
||||
it.shareId == episode.shareId && it.linkId == episode.linkId
|
||||
},
|
||||
playerReady = playerReady,
|
||||
onPlay = { onPlay(playlist, playlist.indexOfFirst { it.linkId == episode.linkId }) },
|
||||
onDownload = { DownloadCoordinator.enqueue(context, episode) },
|
||||
onPause = { DownloadCoordinator.pause(context, it) },
|
||||
onResume = { DownloadCoordinator.resume(context, it) },
|
||||
onSetWatched = { onSetWatched(episode, it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,6 +309,9 @@ internal fun TitleScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** How wide the page's text and controls run on a window wider than a phone. */
|
||||
private val READABLE_WIDTH = 720.dp
|
||||
|
||||
/** One of the title's secondary actions: an icon with its name under it. */
|
||||
@Composable
|
||||
private fun TitleAction(
|
||||
|
||||
@@ -13,6 +13,8 @@ import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import io.narl.protonstream.ui.theme.AppearanceState
|
||||
import io.narl.protonstream.ui.theme.ProtonFallback
|
||||
import io.narl.protonstream.ui.theme.ProtonStreamTheme
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneOffset
|
||||
@@ -25,6 +27,7 @@ import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import uniffi.pstr_android.EpisodeRecord
|
||||
import uniffi.pstr_android.OfflineRecord
|
||||
import uniffi.pstr_android.PaletteRecord
|
||||
import uniffi.pstr_android.SeasonRecord
|
||||
import uniffi.pstr_android.ShareRecord
|
||||
import uniffi.pstr_android.StorageUsageRecord
|
||||
@@ -50,6 +53,14 @@ class ScreenshotTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(ApplicationProvider.getApplicationContext())
|
||||
// The palette is process-wide and the sandbox outlives a test, so a
|
||||
// light capture would otherwise repaint every one after it.
|
||||
AppearanceState.apply(ProtonFallback)
|
||||
}
|
||||
|
||||
private fun captureLight(name: String, content: @Composable () -> Unit) {
|
||||
AppearanceState.apply(LATTE)
|
||||
capture(name, content)
|
||||
}
|
||||
|
||||
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||
@@ -74,6 +85,28 @@ class ScreenshotTest {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library in the light palette`() = captureLight("library-phone-light") {
|
||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, { _, _ -> }, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a title in the light palette`() = captureLight("title-phone-light") {
|
||||
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `a title on a tablet`() = capture("title-tablet") {
|
||||
TitleScreen(SERIES, true, { _, _ -> }, {}, {}, {}, { _, _ -> }, PaddingValues())
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||
fun `the history page on a tablet`() = capture("history-tablet") {
|
||||
HistoryScreen(LIBRARY, { _, _ -> }, {}, { _, _ -> }, PaddingValues(), ZoneOffset.UTC, LocalDate.of(2026, 9, 21))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the library at a large font scale`() = capture("library-phone-large-font") {
|
||||
// Robolectric has no font-scale qualifier; the density is what Compose
|
||||
@@ -142,6 +175,26 @@ class ScreenshotTest {
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Catppuccin Latte with the default accent, as `Palette::resolve` gives it. */
|
||||
val LATTE = PaletteRecord(
|
||||
background = 0xFFEFF1F5u,
|
||||
surface = 0xFFE6E9EFu,
|
||||
sunken = 0xFFDCE0E8u,
|
||||
card = 0xFFCCD0DAu,
|
||||
cardHover = 0xFFBCC0CCu,
|
||||
border = 0xFFBCC0CCu,
|
||||
text = 0xFF4C4F69u,
|
||||
muted = 0xFF6C6F85u,
|
||||
accent = 0xFF732FCCu,
|
||||
accentAlt = 0xFF1856D1u,
|
||||
accentDim = 0xFFC4BBE4u,
|
||||
onAccent = 0xFFEFF1F5u,
|
||||
danger = 0xFFD20F39u,
|
||||
elevated = 0xFFAAAEBDu,
|
||||
dangerDim = 0xFFE7D1D5u,
|
||||
light = true,
|
||||
)
|
||||
|
||||
fun episode(season: UInt, number: UInt, watched: Boolean = false, progress: Double? = null): EpisodeRecord {
|
||||
// `detail` is the filename unless the filename named the episode,
|
||||
// which is what the bridge sends.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 781 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 628 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Reference in new issue
Block a user