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:
nvrl committed 2026-10-29 16:11:24 +01:00
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))
}
}