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.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
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.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
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.Search
|
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.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.narl.protonstream.ui.theme.AccentProgress
|
import io.narl.protonstream.ui.theme.AccentProgress
|
||||||
import uniffi.pstr_android.TitleRecord
|
|
||||||
import uniffi.pstr_android.EpisodeRecord
|
import uniffi.pstr_android.EpisodeRecord
|
||||||
import uniffi.pstr_android.SeasonRecord
|
import uniffi.pstr_android.SeasonRecord
|
||||||
|
import uniffi.pstr_android.TitleRecord
|
||||||
|
import uniffi.pstr_android.TitleType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How many shows the continue-watching shelf holds.
|
* 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. */
|
/** 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)
|
private data class Resumable(val title: TitleRecord, val episode: EpisodeRecord, val index: Int)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LibraryScreen(
|
internal fun LibraryScreen(
|
||||||
state: AppUiState,
|
state: AppUiState,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
onResume: (TitleRecord, Int) -> Unit,
|
onResume: (TitleRecord, Int) -> Unit,
|
||||||
onTitle: (TitleRecord) -> Unit,
|
onTitle: (TitleRecord) -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
padding: PaddingValues,
|
padding: PaddingValues,
|
||||||
) {
|
) {
|
||||||
// Most recently played first, one episode per show: a shelf that lists four
|
// 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) }
|
?.let { Resumable(title, it.value, it.index) }
|
||||||
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
}.sortedByDescending { it.episode.lastPlayed }.take(CONTINUE_WATCHING_MAX)
|
||||||
}
|
}
|
||||||
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
|
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||||
OutlinedTextField(
|
// 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,
|
value = state.query,
|
||||||
onValueChange = onSearch,
|
onValueChange = onSearch,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
shape = CircleShape,
|
||||||
placeholder = { Text("Search library") },
|
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))
|
// Pull to refresh, the gesture every list on a phone answers to; the
|
||||||
if (state.loading) {
|
// icon in the bar stays for a viewer who does not know it.
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
PullToRefreshBox(
|
||||||
} else if (state.titles.isEmpty()) {
|
isRefreshing = state.refreshing,
|
||||||
EmptyState("Your library is empty", "Add a Proton Drive public link under Shares, then refresh.")
|
onRefresh = onRefresh,
|
||||||
} else {
|
modifier = Modifier.fillMaxSize(),
|
||||||
LazyVerticalGrid(
|
) {
|
||||||
columns = GridCells.Adaptive(180.dp),
|
when {
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
CircularProgressIndicator()
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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(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"
|
||||||
|
}
|
||||||
@@ -201,6 +201,7 @@ fun ProtonStreamApp(
|
|||||||
playerMinimized = false
|
playerMinimized = false
|
||||||
},
|
},
|
||||||
{ selectedTitleKey = it.key },
|
{ selectedTitleKey = it.key },
|
||||||
|
model::refresh,
|
||||||
body,
|
body,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -59,13 +59,13 @@ class ScreenshotTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `the library on a phone`() = capture("library-phone") {
|
fun `the library on a phone`() = capture("library-phone") {
|
||||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues())
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
@Config(qualifiers = RobolectricDeviceQualifiers.MediumTablet)
|
||||||
fun `the library on a tablet`() = capture("library-tablet") {
|
fun `the library on a tablet`() = capture("library-tablet") {
|
||||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues())
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -74,13 +74,13 @@ class ScreenshotTest {
|
|||||||
// reads it from, so the scale is set there.
|
// reads it from, so the scale is set there.
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale = 1.5f)) {
|
||||||
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, PaddingValues())
|
LibraryScreen(LIBRARY, {}, { _, _ -> }, {}, {}, PaddingValues())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an empty library`() = capture("library-empty") {
|
fun `an empty library`() = capture("library-empty") {
|
||||||
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, PaddingValues())
|
LibraryScreen(AppUiState(loading = false), {}, { _, _ -> }, {}, {}, PaddingValues())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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 |