feat(meta,core,app,android,ios): cast and trailer

AniList's fragment now asks for the first ten characters, main roles
first, with their Japanese voice actor, and for the trailer's site and
id. Both are stored in the existing details JSON as TitleDetails.cast and
.trailer with serde defaults, so no migration is needed and details
stored before them still read. The details REVISION goes to 2, so a
stored match is refetched by its id on the next metadata run and the
cast fills in without anyone matching the title again.

The title page shows a Cast row of round portraits with the character's
name over the actor's. Clicking a tile opens the Person browse page for
that actor, which now also admits voice actors, and search counts them
under People and studios. A Trailer button opens the YouTube or
Dailymotion page in the browser; the app itself makes no request to
either, and an unknown site or an id with odd characters gives no link.
This commit is contained in:
nvrl committed 2026-10-31 01:56:41 +01:00
1 parent 87ccaf26e1
commit 7bfebc5fd5
15 files changed
+539 -19

No files matched your search

@@ -92,6 +92,8 @@ import io.narl.protonstream.ui.theme.TonalButton
import io.narl.protonstream.ui.theme.solid
import androidx.compose.foundation.border
import uniffi.pstr_android.BrowseFacet
import uniffi.pstr_android.CastRecord
import androidx.compose.material.icons.filled.Movie
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
import uniffi.pstr_android.EpisodeRecord
@@ -351,6 +353,21 @@ internal fun TitleScreen(
.clickable { overviewOpen = !overviewOpen },
)
}
// In the browser: the app itself never requests it.
title.trailerUrl?.let { url ->
QuietButton(
onClick = {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri()))
}.onFailure(onPreferenceError)
},
modifier = Modifier.padding(top = 4.dp),
) {
Icon(Icons.Default.Movie, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Trailer")
}
}
// Genres as chips, then the provider's tags quieter after
// them: a tag is its community describing it, not what it
// is filed as.
@@ -492,6 +509,26 @@ internal fun TitleScreen(
}
}
}
if (title.cast.isNotEmpty()) {
item(key = "cast") {
Column(Modifier.padding(top = 16.dp)) {
Text(
"Cast",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = side + 16.dp),
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(start = side + 16.dp, end = side + 16.dp, top = 10.dp),
) {
// Unkeyed: two characters can share a name.
items(title.cast) { member ->
CastTile(member) { actor -> onBrowse(BrowseFacet.Person(actor)) }
}
}
}
}
}
// Under the episodes rather than over them: where to go once this
// one is done, not what the page is for.
if (moreLike.isNotEmpty()) {
@@ -582,6 +619,48 @@ private fun TitleBar(name: String, list: LazyListState, backdrop: Float, onBack:
private val TITLE_BAR = 56.dp
/**
* One character: a round portrait over their name and who voices them. A voice
* actor opens every title in the library they are in.
*/
@Composable
private fun CastTile(member: CastRecord, onActor: (String) -> Unit) {
Column(
Modifier
.width(88.dp)
.clip(MaterialTheme.shapes.medium)
.then(
member.actor?.let { actor ->
Modifier.clickable(onClickLabel = "Browse $actor") { onActor(actor) }
} ?: Modifier,
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
RemoteArtwork(
member.characterImage ?: member.actorImage,
member.character,
Modifier.size(76.dp).clip(CircleShape),
labelled = false,
)
Text(
member.character,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 6.dp),
)
member.actor?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/**
* One part of the franchise, or one title like this one: its cover, what it is
* and when. In the franchise row this title's own is marked rather than left
@@ -36,6 +36,7 @@ import uniffi.pstr_android.ShareRecord
import uniffi.pstr_android.ShelfRecord
import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.TileRecord
import uniffi.pstr_android.CastRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType
@@ -291,6 +292,11 @@ class ScreenshotTest {
directors = listOf("Daisuke Hiramaki"),
tags = listOf("Idol", "Reincarnation"),
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"),
trailerUrl = "https://www.youtube.com/watch?v=trailer",
cast = listOf(
CastRecord("Ai Hoshino", null, "Rie Takahashi", null),
CastRecord("Aqua", null, "Takeo Ootsuka", null),
),
)
/** The rest of [SERIES]'s franchise, for the title page's row of it. */
Binary file not shown.

Before

Width:  |  Height:  |  Size: 648 KiB

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 KiB

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB