mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(core,meta,app,android,ios): title logos and runtimes
ani.zip sent each series' TVDB clear logo and every episode's running time from the start, and neither was kept. Enrichment now stores the logo in title_metadata.logo_url (kept across a refresh of the same entry, like the fanart) and episode runtimes in episode_metadata.runtime, TVDB's first and AniDB's length after it. AniList's duration lands in TitleDetails::runtime; TMDB episode runtimes are kept too. Schema v13 adds the two columns and marks AniList titles un-enriched without deleting anything. TitleDetails::revision lets details written before runtime existed refetch by id while the old ones stay on screen, instead of blanking them in the migration as v10 did. Clear logos are padded to a square, so each client crops them to their visible pixels before drawing. The logo replaces the name on the library's banner and the title page (not on a light theme's title page, where lettering made for dark art can vanish). Episode rows show how long they run, in place of the file size on Android and iOS; a film's runtime joins its line of facts.
This commit is contained in:
1 parent
b5ba59b41d
commit
bb5a6638c4
29 files changed
+845
-69
No files matched your search
@@ -137,6 +137,17 @@ internal fun nextUpIndex(playlist: List<EpisodeRecord>, continueIndex: UInt? = n
|
||||
?: playlist.indexOfFirst { !it.watched }.takeIf { it >= 0 }
|
||||
?: 0
|
||||
|
||||
/** `24 min`, `2 h 4 min`, `2 h`: a provider's running time in minutes. */
|
||||
internal fun formatRuntime(minutes: UInt): String {
|
||||
val hours = minutes / 60u
|
||||
val rest = minutes % 60u
|
||||
return when {
|
||||
hours == 0u -> "$rest min"
|
||||
rest == 0u -> "$hours h"
|
||||
else -> "$hours h $rest min"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatBytes(bytes: ULong): String {
|
||||
val value = bytes.toDouble()
|
||||
return when {
|
||||
|
||||
@@ -48,6 +48,7 @@ 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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
@@ -640,13 +641,21 @@ private fun FeaturedBanner(title: TitleRecord, resume: Resumable?, onPlay: (Int)
|
||||
),
|
||||
)
|
||||
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
|
||||
Text(
|
||||
// Always over the dark scrim above, so a logo made for dark art
|
||||
// reads here whatever the theme.
|
||||
RemoteLogo(
|
||||
title.logoUrl,
|
||||
title.displayName,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Modifier.fillMaxWidth(0.6f).heightIn(max = 72.dp).padding(bottom = 6.dp),
|
||||
) {
|
||||
Text(
|
||||
title.displayName,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
listOfNotNull(
|
||||
title.rating?.let { "★ %.1f".format(it) },
|
||||
|
||||
@@ -112,6 +112,84 @@ internal fun RemoteArtwork(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A title's logo — its name as lettering — in place of [fallback], once it has
|
||||
* loaded. [fallback] stands until then, and for good when there is none, so a
|
||||
* title without a logo looks as it always did and one with a logo only gains.
|
||||
*
|
||||
* Trimmed to what is not transparent before it is drawn: TVDB pads every clear
|
||||
* logo to a square, and the padding would otherwise decide its size and place.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RemoteLogo(
|
||||
url: String?,
|
||||
description: String,
|
||||
modifier: Modifier = Modifier,
|
||||
fallback: @Composable () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val enabled = LocalArtworkLoading.current
|
||||
var logo by remember(url) { mutableStateOf<ImageBitmap?>(null) }
|
||||
LaunchedEffect(url, enabled) {
|
||||
logo = if (enabled) loadLogo(context, url)?.asImageBitmap() else null
|
||||
}
|
||||
when (val loaded = logo) {
|
||||
null -> fallback()
|
||||
else -> Image(
|
||||
loaded,
|
||||
description,
|
||||
modifier,
|
||||
alignment = Alignment.BottomStart,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadLogo(context: Context, url: String?): Bitmap? {
|
||||
val requested = url ?: return null
|
||||
val key = "logo:$requested"
|
||||
decoded.get(key)?.let { return it }
|
||||
val bitmap = loadArtwork(context, requested, LOGO_EDGE) ?: return null
|
||||
return withContext(Dispatchers.Default) { trimTransparent(bitmap) }.also { decoded.put(key, it) }
|
||||
}
|
||||
|
||||
/** [bitmap] cropped to [opaqueBounds], or itself when there is nothing to crop. */
|
||||
private fun trimTransparent(bitmap: Bitmap): Bitmap {
|
||||
if (!bitmap.hasAlpha()) return bitmap
|
||||
val pixels = IntArray(bitmap.width * bitmap.height)
|
||||
bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height)
|
||||
val (left, top, right, bottom) = opaqueBounds(pixels, bitmap.width) ?: return bitmap
|
||||
if (left == 0 && top == 0 && right == bitmap.width - 1 && bottom == bitmap.height - 1) return bitmap
|
||||
return Bitmap.createBitmap(bitmap, left, top, right - left + 1, bottom - top + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The inclusive `[left, top, right, bottom]` of the pixels in an ARGB [pixels]
|
||||
* row-major image of [width] that are not (nearly) transparent; `null` when
|
||||
* none are. Below the threshold is anti-aliasing haze, not lettering.
|
||||
*/
|
||||
internal fun opaqueBounds(pixels: IntArray, width: Int): IntArray? {
|
||||
var left = Int.MAX_VALUE
|
||||
var top = Int.MAX_VALUE
|
||||
var right = -1
|
||||
var bottom = -1
|
||||
for (index in pixels.indices) {
|
||||
if ((pixels[index] ushr 24) <= VISIBLE_ALPHA) continue
|
||||
val x = index % width
|
||||
val y = index / width
|
||||
if (x < left) left = x
|
||||
if (x > right) right = x
|
||||
if (y < top) top = y
|
||||
if (y > bottom) bottom = y
|
||||
}
|
||||
return if (right < 0) null else intArrayOf(left, top, right, bottom)
|
||||
}
|
||||
|
||||
private const val VISIBLE_ALPHA = 8
|
||||
|
||||
/** A logo is drawn a few hundred pixels wide at most. */
|
||||
private const val LOGO_EDGE = 800
|
||||
|
||||
private sealed interface ArtworkLoad {
|
||||
data object Pending : ArtworkLoad
|
||||
data object Missing : ArtworkLoad
|
||||
|
||||
@@ -92,6 +92,7 @@ import androidx.compose.material.icons.filled.Replay
|
||||
import androidx.compose.material.icons.filled.DownloadDone
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
@Composable
|
||||
@@ -211,7 +212,21 @@ internal fun TitleScreen(
|
||||
}
|
||||
item(key = "heading") {
|
||||
Column(Modifier.padding(horizontal = side + 16.dp)) {
|
||||
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
|
||||
val heading = @Composable {
|
||||
Text(title.displayName, style = MaterialTheme.typography.headlineMedium)
|
||||
}
|
||||
// A light page keeps the name: a clear logo is lettering
|
||||
// made for dark art, white as often as not.
|
||||
if (MaterialTheme.colorScheme.background.luminance() < 0.5f) {
|
||||
RemoteLogo(
|
||||
title.logoUrl,
|
||||
title.displayName,
|
||||
Modifier.fillMaxWidth(0.7f).heightIn(max = 88.dp).padding(bottom = 4.dp),
|
||||
heading,
|
||||
)
|
||||
} else {
|
||||
heading()
|
||||
}
|
||||
// The other names it goes by: the share's when the shown one
|
||||
// is the provider's, and the original-language one.
|
||||
listOfNotNull(
|
||||
@@ -224,6 +239,8 @@ internal fun TitleScreen(
|
||||
listOfNotNull(
|
||||
title.formatLabel,
|
||||
title.seasonLabel ?: (title.metadataYear ?: title.year)?.toString(),
|
||||
// A series' running time is its episodes', which each row says.
|
||||
title.runtime?.takeIf { title.kind == TitleType.FILM }?.let(::formatRuntime),
|
||||
title.rating?.let { "★ %.1f".format(it) },
|
||||
"${title.watchedCount} of ${title.episodeCount} watched".takeIf { title.episodeCount > 1uL },
|
||||
).joinToString(" · "),
|
||||
@@ -625,7 +642,9 @@ private fun EpisodeRow(
|
||||
Text(
|
||||
listOfNotNull(
|
||||
numbering,
|
||||
episode.size?.let(::formatBytes),
|
||||
// How long it runs says more than how big it is; the
|
||||
// size is what there is when no provider said.
|
||||
episode.runtime?.let(::formatRuntime) ?: episode.size?.let(::formatBytes),
|
||||
episode.airDate,
|
||||
).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package io.narl.protonstream.ui
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class LogoAndRuntimeTest {
|
||||
@Test
|
||||
fun `a padded logo is bounded by its lettering and not its haze`() {
|
||||
val width = 10
|
||||
val pixels = IntArray(width * 6)
|
||||
// Lettering from (2, 1) to (6, 3), and a pixel of haze in the corner.
|
||||
for (y in 1..3) for (x in 2..6) pixels[y * width + x] = 0xFFFFFFFF.toInt()
|
||||
pixels[5 * width + 9] = 0x03FFFFFF
|
||||
assertArrayEquals(intArrayOf(2, 1, 6, 3), opaqueBounds(pixels, width))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a logo with nothing visible has no bounds`() {
|
||||
assertNull(opaqueBounds(IntArray(16), 4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a running time reads in hours and minutes`() {
|
||||
assertEquals("24 min", formatRuntime(24u))
|
||||
assertEquals("2 h", formatRuntime(120u))
|
||||
assertEquals("2 h 4 min", formatRuntime(124u))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user