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 io.narl.protonstream.ui.theme.solid
import androidx.compose.foundation.border import androidx.compose.foundation.border
import uniffi.pstr_android.BrowseFacet 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.TitleRecord
import uniffi.pstr_android.TitleType import uniffi.pstr_android.TitleType
import uniffi.pstr_android.EpisodeRecord import uniffi.pstr_android.EpisodeRecord
@@ -351,6 +353,21 @@ internal fun TitleScreen(
.clickable { overviewOpen = !overviewOpen }, .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 // Genres as chips, then the provider's tags quieter after
// them: a tag is its community describing it, not what it // them: a tag is its community describing it, not what it
// is filed as. // 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 // Under the episodes rather than over them: where to go once this
// one is done, not what the page is for. // one is done, not what the page is for.
if (moreLike.isNotEmpty()) { if (moreLike.isNotEmpty()) {
@@ -582,6 +619,48 @@ private fun TitleBar(name: String, list: LazyListState, backdrop: Float, onBack:
private val TITLE_BAR = 56.dp 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 * 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 * 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.ShelfRecord
import uniffi.pstr_android.StorageUsageRecord import uniffi.pstr_android.StorageUsageRecord
import uniffi.pstr_android.TileRecord import uniffi.pstr_android.TileRecord
import uniffi.pstr_android.CastRecord
import uniffi.pstr_android.TitleRecord import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TitleType import uniffi.pstr_android.TitleType
@@ -291,6 +292,11 @@ class ScreenshotTest {
directors = listOf("Daisuke Hiramaki"), directors = listOf("Daisuke Hiramaki"),
tags = listOf("Idol", "Reincarnation"), tags = listOf("Idol", "Reincarnation"),
franchise = listOf("oshi-no-ko", "oshi-no-ko-film"), 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. */ /** 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

+33 -1
View File
@@ -545,7 +545,7 @@ pub fn airing_countdown(episode: u32, at: i64, now: i64) -> String {
} }
/// What kind of thing a [`BrowseFacet`] names — `Genre`, `Studio`, /// What kind of thing a [`BrowseFacet`] names — `Genre`, `Studio`,
/// `Director` — for over the heading of its page. /// `Person` — for over the heading of its page.
#[uniffi::export] #[uniffi::export]
pub fn facet_kind(facet: BrowseFacet) -> String { pub fn facet_kind(facet: BrowseFacet) -> String {
facet.core().kind().to_owned() facet.core().kind().to_owned()
@@ -706,6 +706,23 @@ pub struct TitleRecord {
/// `New`, or `3 new` on a show being watched; see `pstr_core::news`. /// `New`, or `3 new` on a show being watched; see `pstr_core::news`.
#[uniffi(default = None)] #[uniffi(default = None)]
pub badge: Option<String>, pub badge: Option<String>,
/// The characters, main ones first, with who voices them.
#[uniffi(default = [])]
pub cast: Vec<CastRecord>,
/// The trailer's page, to open in the browser; see
/// `pstr_core::metadata::Trailer::url`.
#[uniffi(default = None)]
pub trailer_url: Option<String>,
}
/// One character and who voices them: the shared
/// `pstr_core::metadata::CastMember`.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct CastRecord {
pub character: String,
pub character_image: Option<String>,
pub actor: Option<String>,
pub actor_image: Option<String>,
} }
/// Playback preferences that outlive one file, one title and one launch. /// Playback preferences that outlive one file, one title and one launch.
@@ -3893,6 +3910,21 @@ fn title_record(
next_airing_at: None, next_airing_at: None,
on_list: false, on_list: false,
badge: None, badge: None,
cast: details.map_or_else(Vec::new, |details| {
details
.cast
.iter()
.map(|member| CastRecord {
character: member.character.clone(),
character_image: member.character_image.clone(),
actor: member.actor.clone(),
actor_image: member.actor_image.clone(),
})
.collect()
}),
trailer_url: details
.and_then(|details| details.trailer.as_ref())
.and_then(|trailer| trailer.url()),
franchise: Vec::new(), franchise: Vec::new(),
logo_url: metadata.and_then(|found| found.logo_url.clone()), logo_url: metadata.and_then(|found| found.logo_url.clone()),
runtime: details.and_then(|details| details.runtime), runtime: details.and_then(|details| details.runtime),
+11
View File
@@ -81,6 +81,17 @@ impl Art<'_> {
.texture(&thumbnail_key(node), || engine.request_thumbnail(node)) .texture(&thumbnail_key(node), || engine.request_thumbnail(node))
} }
/// A provider's picture that belongs to no one title's tile — a cast
/// member's portrait — fetched like a poster the first time it is asked
/// for.
pub fn picture(&mut self, url: &str) -> Option<egui::TextureHandle> {
let key = format!("picture:{url}");
let engine = self.engine;
let url = url.to_owned();
self.posters
.texture(&key, || engine.request_poster(key.clone(), url))
}
/// The name to show for `title`. See [`title_name`]. /// The name to show for `title`. See [`title_name`].
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str { pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
title_name(title, self.metadata, self.names) title_name(title, self.metadata, self.names)
+124 -5
View File
@@ -151,6 +151,20 @@ pub fn show(
} }
ui.add_space(theme::space::L); ui.add_space(theme::space::L);
let cast = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|found| found.details.as_ref())
.map(|details| details.cast.clone())
.unwrap_or_default();
if !cast.is_empty() {
ui.add_space(theme::space::L);
ui::section(ui, "Cast");
cast_row(ui, art, &title.key, &cast, actions);
ui.add_space(theme::space::L);
}
// Under the episodes rather than over them: it is where to go // Under the episodes rather than over them: it is where to go
// once this one is done, not what the page is for. // once this one is done, not what the page is for.
let similar: Vec<&Title> = similar.iter().filter_map(|key| library.get(key)).collect(); let similar: Vec<&Title> = similar.iter().filter_map(|key| library.get(key)).collect();
@@ -163,6 +177,95 @@ pub fn show(
}); });
} }
/// The characters, each a round portrait over their name and who voices
/// them. A voice actor opens every title in the library they are in.
fn cast_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
key: &str,
cast: &[pstr_core::metadata::CastMember],
actions: &mut Vec<Action>,
) {
const SIZE: f32 = 88.0;
ui::shelf(ui, ("cast", key), |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = theme::space::L;
for (i, member) in cast.iter().enumerate() {
let tile = ui.allocate_ui(egui::vec2(SIZE + 16.0, SIZE + 52.0), |ui| {
ui.vertical_centered(|ui| {
let (rect, _) =
ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::hover());
let picture = member
.character_image
.as_deref()
.or(member.actor_image.as_deref())
.and_then(|url| art.picture(url));
let painter = ui.painter();
match picture {
Some(texture) => {
let shape = egui::epaint::RectShape::filled(
rect,
egui::CornerRadius::same((SIZE / 2.0) as u8),
egui::Color32::WHITE,
)
.with_texture(
texture.id(),
super::cover_uv(texture.size_vec2(), rect.size()),
);
painter.add(shape);
}
None => {
painter.circle_filled(rect.center(), SIZE / 2.0, theme::card());
}
}
ui.add(
egui::Label::new(
theme::Role::Caption
.rich(&member.character)
.color(theme::text()),
)
.truncate()
.selectable(false),
);
if let Some(actor) = &member.actor {
ui.add(
egui::Label::new(ui::muted(actor))
.truncate()
.selectable(false),
);
}
rect
})
.inner
});
// The whole tile opens the actor's page, names included, not only the portrait.
let Some(actor) = &member.actor else { continue };
let response = ui
.interact(
tile.response.rect,
ui.id().with(("cast", i)),
egui::Sense::click(),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!("Everything in the library with {actor}"));
if response.hovered() {
ui.painter().circle_stroke(
tile.inner.center(),
SIZE / 2.0,
egui::Stroke::new(2.0, theme::accent()),
);
}
if response.clicked() {
actions.push(Action::Goto(Page::Browse {
facet: pstr_core::discover::Facet::Person(actor.clone()),
from: Some(key.to_owned()),
}));
}
}
});
});
}
/// A row of covers: every title of the franchise in release order, with /// A row of covers: every title of the franchise in release order, with
/// `current` marked rather than left out, so the row says where it sits in /// `current` marked rather than left out, so the row says where it sits in
/// the story — or, with no `current`, the titles like this one. /// the story — or, with no `current`, the titles like this one.
@@ -739,12 +842,28 @@ fn description(ui: &mut egui::Ui, found: &TitleMetadata, key: &str, actions: &mu
} }
} }
} }
if let Some(url) = &found.url { let trailer = details
.and_then(|details| details.trailer.as_ref())
.and_then(|trailer| trailer.url());
if found.url.is_some() || trailer.is_some() {
ui.add_space(theme::space::M); ui.add_space(theme::space::M);
ui.hyperlink_to( ui.horizontal(|ui| {
ui::muted(format!("More on {}", found.provider.label())), // In the browser: the app itself never requests the trailer.
url, if let Some(trailer) = &trailer {
); ui.hyperlink_to(
ui::muted(format!("{} Trailer", egui_phosphor::regular::FILM_STRIP)),
trailer,
)
.on_hover_text("Opens in your browser");
ui.add_space(theme::space::L);
}
if let Some(url) = &found.url {
ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())),
url,
);
}
});
} }
} }
+23 -9
View File
@@ -16,7 +16,7 @@ use crate::shelves::Shelf;
pub enum Facet { pub enum Facet {
Genre(String), Genre(String),
Studio(String), Studio(String),
/// A director. /// A director, or a voice actor in the cast.
Person(String), Person(String),
} }
@@ -33,7 +33,7 @@ impl Facet {
match self { match self {
Self::Genre(_) => "Genre", Self::Genre(_) => "Genre",
Self::Studio(_) => "Studio", Self::Studio(_) => "Studio",
Self::Person(_) => "Director", Self::Person(_) => "Person",
} }
} }
@@ -45,9 +45,13 @@ impl Facet {
Self::Studio(name) => { Self::Studio(name) => {
details.is_some_and(|details| details.studios.iter().any(|studio| studio == name)) details.is_some_and(|details| details.studios.iter().any(|studio| studio == name))
} }
Self::Person(name) => { Self::Person(name) => details.is_some_and(|details| {
details.is_some_and(|details| details.directors.iter().any(|person| person == name)) details.directors.iter().any(|person| person == name)
} || details
.cast
.iter()
.any(|member| member.actor.as_deref() == Some(name.as_str()))
}),
} }
} }
@@ -161,17 +165,27 @@ pub fn find(
let Some(details) = &found.details else { let Some(details) = &found.details else {
continue; continue;
}; };
let made = details // One count per title, however many characters a voice actor has
// in it.
let made: HashSet<Facet> = details
.directors .directors
.iter() .iter()
.chain(
details
.cast
.iter()
.filter_map(|member| member.actor.as_ref()),
)
.map(|name| Facet::Person(name.clone())) .map(|name| Facet::Person(name.clone()))
.chain( .chain(
details details
.studios .studios
.iter() .iter()
.map(|name| Facet::Studio(name.clone())), .map(|name| Facet::Studio(name.clone())),
); )
for facet in made.filter(|facet| holds(facet.name())) { .filter(|facet| holds(facet.name()))
.collect();
for facet in made {
*people.entry(facet).or_default() += 1; *people.entry(facet).or_default() += 1;
} }
} }
@@ -570,7 +584,7 @@ mod tests {
3, 3,
"both Frieren titles and Bocchi" "both Frieren titles and Bocchi"
); );
assert_eq!(person.kind(), "Director"); assert_eq!(person.kind(), "Person");
let genre = Facet::Genre("Comedy".into()); let genre = Facet::Genre("Comedy".into());
let mut comedy = genre.titles(&library.titles, &metadata); let mut comedy = genre.titles(&library.titles, &metadata);
comedy.sort(); comedy.sort();
+84 -2
View File
@@ -180,6 +180,11 @@ pub struct TitleDetails {
/// Minutes: an episode's length for a series, the whole running time for /// Minutes: an episode's length for a series, the whole running time for
/// a film. /// a film.
pub runtime: Option<u32>, pub runtime: Option<u32>,
/// The main characters and then the supporting ones, each with who voices
/// them in the original language.
pub cast: Vec<CastMember>,
/// Where the provider says its trailer is.
pub trailer: Option<Trailer>,
/// Which [`TitleDetails::REVISION`] wrote this. A stored record from an /// Which [`TitleDetails::REVISION`] wrote this. A stored record from an
/// older one lacks a field a newer build asks for, and is fetched again /// older one lacks a field a newer build asks for, and is fetched again
/// by id — see `pstr_meta::service::plan`. Not a reason to blank it in a /// by id — see `pstr_meta::service::plan`. Not a reason to blank it in a
@@ -193,12 +198,56 @@ pub struct TitleDetails {
pub chain: Vec<String>, pub chain: Vec<String>,
} }
/// One character, and who voices them.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CastMember {
pub character: String,
/// The character's portrait.
pub character_image: Option<String>,
/// `MAIN`, `SUPPORTING` or `BACKGROUND`.
pub role: Option<String>,
/// The voice actor in the original language.
pub actor: Option<String>,
pub actor_image: Option<String>,
}
/// A trailer, as the provider names it: a site and that site's id for it.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Trailer {
/// `youtube` or `dailymotion`.
pub site: String,
pub id: String,
}
impl Trailer {
/// The trailer's page, to open in the browser. The app itself never
/// requests it. `None` for a site this build does not know.
pub fn url(&self) -> Option<String> {
let id: String = self
.id
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
.collect();
if id.is_empty() {
return None;
}
match self.site.to_ascii_lowercase().as_str() {
"youtube" => Some(format!("https://www.youtube.com/watch?v={id}")),
"dailymotion" => Some(format!("https://www.dailymotion.com/video/{id}")),
_ => None,
}
}
}
impl TitleDetails { impl TitleDetails {
/// What a provider that fills these in today writes into /// What a provider that fills these in today writes into
/// [`TitleDetails::revision`]. Raise it with a field that existing /// [`TitleDetails::revision`]. Raise it with a field that existing
/// records should be fetched again for. `1` added /// records should be fetched again for. `1` added
/// [`TitleDetails::runtime`]. /// [`TitleDetails::runtime`], `2` [`TitleDetails::cast`] and
pub const REVISION: u32 = 1; /// [`TitleDetails::trailer`].
pub const REVISION: u32 = 2;
/// Whether these were written by this build's [`TitleDetails::REVISION`] /// Whether these were written by this build's [`TitleDetails::REVISION`]
/// or a later one. /// or a later one.
@@ -603,6 +652,39 @@ impl Default for MetadataConfig {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_trailer_opens_on_its_own_site_and_an_unknown_site_or_odd_id_opens_nothing() {
let trailer = |site: &str, id: &str| Trailer {
site: site.into(),
id: id.into(),
};
assert_eq!(
trailer("youtube", "tR8YH0G67Rk").url().as_deref(),
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
);
assert_eq!(
trailer("dailymotion", "x7tgad0").url().as_deref(),
Some("https://www.dailymotion.com/video/x7tgad0")
);
assert_eq!(trailer("vimeo", "1").url(), None);
// Whatever the provider sent, only an id's own characters reach the
// address.
assert_eq!(
trailer("youtube", "a&b=c").url().as_deref(),
Some("https://www.youtube.com/watch?v=abc")
);
assert_eq!(trailer("youtube", "&?").url(), None);
}
#[test]
fn details_stored_before_the_cast_existed_still_read() {
let old = r#"{"studios":["MAPPA"],"revision":1}"#;
let details: TitleDetails = serde_json::from_str(old).unwrap();
assert!(details.cast.is_empty());
assert_eq!(details.trailer, None);
assert!(!details.is_current(), "fetched again for the cast");
}
#[test] #[test]
fn a_status_is_said_for_a_person_and_an_unknown_one_is_not_said_at_all() { fn a_status_is_said_for_a_person_and_an_unknown_one_is_not_said_at_all() {
let status = |value: &str| { let status = |value: &str| {
+115 -2
View File
@@ -18,7 +18,8 @@ use std::collections::HashSet;
use pstr_core::library::{self, TitleKind}; use pstr_core::library::{self, TitleKind};
use pstr_core::metadata::{ use pstr_core::metadata::{
Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata, CastMember, Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
Trailer,
}; };
use serde::Deserialize; use serde::Deserialize;
@@ -60,7 +61,9 @@ const SCORE_SCALE: f32 = 10.0;
/// Everything one entry is asked for, by search and by id alike. /// Everything one entry is asked for, by search and by id alike.
/// ///
/// The relations are the franchise's — see `pstr_core::franchise` — and the /// The relations are the franchise's — see `pstr_core::franchise` — and the
/// staff are only the most relevant few, which is where a director sits. /// staff are only the most relevant few, which is where a director sits. The
/// characters are the first ten by role, main ones first, each with their
/// Japanese voice actor.
macro_rules! entry_fragment { macro_rules! entry_fragment {
() => { () => {
r#" r#"
@@ -84,6 +87,14 @@ fragment entry on Media {
siteUrl siteUrl
studios(isMain: true) { nodes { name } } studios(isMain: true) { nodes { name } }
staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } } staff(sort: RELEVANCE, perPage: 6) { edges { role node { name { full } } } }
characters(sort: [ROLE, RELEVANCE, ID], perPage: 10) {
edges {
role
node { name { full } image { medium } }
voiceActors(language: JAPANESE, sort: [RELEVANCE, ID]) { name { full } image { medium } }
}
}
trailer { id site }
nextAiringEpisode { episode airingAt } nextAiringEpisode { episode airingAt }
relations { edges { relationType node { id type } } } relations { edges { relationType node { id type } } }
} }
@@ -591,6 +602,8 @@ struct Media {
site_url: Option<String>, site_url: Option<String>,
studios: Option<Studios>, studios: Option<Studios>,
staff: Option<Staff>, staff: Option<Staff>,
characters: Option<Characters>,
trailer: Option<AniTrailer>,
#[serde(rename = "nextAiringEpisode")] #[serde(rename = "nextAiringEpisode")]
next_airing_episode: Option<NextAiring>, next_airing_episode: Option<NextAiring>,
relations: Option<Relations>, relations: Option<Relations>,
@@ -636,6 +649,32 @@ struct StaffEdge {
#[derive(Deserialize)] #[derive(Deserialize)]
struct Person { struct Person {
name: PersonName, name: PersonName,
image: Option<Image>,
}
#[derive(Deserialize)]
struct Image {
medium: Option<String>,
}
#[derive(Deserialize)]
struct Characters {
#[serde(default)]
edges: Vec<CharacterEdge>,
}
#[derive(Deserialize)]
struct CharacterEdge {
role: Option<String>,
node: Option<Person>,
#[serde(rename = "voiceActors", default)]
voice_actors: Vec<Person>,
}
#[derive(Deserialize)]
struct AniTrailer {
id: Option<String>,
site: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -759,6 +798,35 @@ impl Media {
popularity: self.popularity, popularity: self.popularity,
status: self.status, status: self.status,
runtime: self.duration.filter(|minutes| *minutes > 0), runtime: self.duration.filter(|minutes| *minutes > 0),
cast: self
.characters
.map(|characters| {
characters
.edges
.into_iter()
.filter_map(|edge| {
let character = edge.node?;
let actor = edge.voice_actors.into_iter().next();
Some(CastMember {
character: character.name.full?,
character_image: character.image.and_then(|image| image.medium),
role: edge.role,
actor_image: actor
.as_ref()
.and_then(|actor| actor.image.as_ref())
.and_then(|image| image.medium.clone()),
actor: actor.and_then(|actor| actor.name.full),
})
})
.collect()
})
.unwrap_or_default(),
trailer: self.trailer.and_then(|trailer| {
Some(Trailer {
site: trailer.site?,
id: trailer.id?,
})
}),
revision: TitleDetails::REVISION, revision: TitleDetails::REVISION,
next_episode: self next_episode: self
.next_airing_episode .next_airing_episode
@@ -973,6 +1041,51 @@ mod tests {
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg")); assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
} }
#[test]
fn the_cast_comes_with_its_voice_actors_and_the_trailer_with_its_site() {
let media: Media = serde_json::from_str(
r#"{
"id": 154587,
"title": {"romaji": "Sousou no Frieren", "english": null, "native": null},
"characters": {"edges": [
{"role": "MAIN",
"node": {"name": {"full": "Frieren"}, "image": {"medium": "frieren.png"}},
"voiceActors": [{"name": {"full": "Atsumi Tanezaki"},
"image": {"medium": "tanezaki.png"}}]},
{"role": "SUPPORTING",
"node": {"name": {"full": "Himmel"}, "image": null},
"voiceActors": []},
{"role": "BACKGROUND", "node": {"name": {"full": null}}, "voiceActors": []}
]},
"trailer": {"id": "tR8YH0G67Rk", "site": "youtube"}
}"#,
)
.expect("parse");
let details = media.into_candidate().metadata.details.expect("details");
assert_eq!(
details.cast,
[
CastMember {
character: "Frieren".into(),
character_image: Some("frieren.png".into()),
role: Some("MAIN".into()),
actor: Some("Atsumi Tanezaki".into()),
actor_image: Some("tanezaki.png".into()),
},
// No voice actor yet is still a character; no name is not.
CastMember {
character: "Himmel".into(),
role: Some("SUPPORTING".into()),
..CastMember::default()
},
]
);
assert_eq!(
details.trailer.and_then(|trailer| trailer.url()).as_deref(),
Some("https://www.youtube.com/watch?v=tR8YH0G67Rk")
);
}
fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge { fn edge(relation: &str, id: i64, kind: &str, format: &str) -> RelationEdge {
RelationEdge { RelationEdge {
relation_type: Some(relation.into()), relation_type: Some(relation.into()),
+11
View File
@@ -162,6 +162,17 @@ match. The same scoring, seeded by the title most recently watched to the end
and limited to titles not started, is the library's *Because you watched* row. and limited to titles not started, is the library's *Because you watched* row.
No request: it reads only what the match already stored. No request: it reads only what the match already stored.
**Cast and trailer.** The AniList query also asks for the first ten
characters by role, main ones first, each with their Japanese voice actor and
both portraits, and for the trailer's site and id. They are
`TitleDetails::cast` and `TitleDetails::trailer`, in the same JSON column — no
migration. `TitleDetails::REVISION` is 2, so a match stored before them is
fetched again by id on the next match run, as `runtime` was. A voice actor's
chip opens the same page as a director's (`Facet::Person`). The trailer is
only ever a link: `Trailer::url` builds the YouTube or Dailymotion address,
keeping only an id's own characters, and the browser opens it. Portraits are
fetched like posters, from AniList's image host.
**Android.** The same, through the bridge: `AndroidEngine::arrangement` **Android.** The same, through the bridge: `AndroidEngine::arrangement`
returns the grid's tiles and the shelves as keys into `library`'s titles, and returns the grid's tiles and the shelves as keys into `library`'s titles, and
each `TitleRecord` carries its display name, franchise and details. The each `TitleRecord` carries its display name, franchise and details. The
+3
View File
@@ -18,6 +18,9 @@ Nothing else leaves your device. There is no telemetry, no crash reporting and n
of the titles in your library. It does not learn your files, your watch history or your shares. of the titles in your library. It does not learn your files, your watch history or your shares.
Turning them off deletes every stored answer. Turning them off deletes every stored answer.
**Trailer** on a title's page opens the trailer's YouTube or Dailymotion page in your web
browser. The app sends nothing there itself; your browser loads the page as it would any link.
## Secrets ## Secrets
- **The part of a share link after `#`** is the key that decrypts the share. The app treats it as - **The part of a share link after `#`** is the key that decrypts the share. The app treats it as
+6
View File
@@ -114,6 +114,12 @@ top.
franchise, and shows one title from each other franchise. It needs posters and descriptions. franchise, and shows one title from each other franchise. It needs posters and descriptions.
- The genre chips under the description open a page with every title of that genre in your - The genre chips under the description open a page with every title of that genre in your
library. So do the **Studio** and **Director** chips. Back, or **Esc**, returns to the title. library. So do the **Studio** and **Director** chips. Back, or **Esc**, returns to the title.
- **Cast** lists the main characters and who voices them in Japanese. Click one to see every title
in your library with that voice actor. Search finds voice actors too.
- **Trailer** opens the title's trailer in your web browser. The app does not play it or load it
itself.
- Titles matched before this version get their cast and trailer by themselves the next time the
app looks up posters and descriptions.
- With posters and descriptions on, a show's logo takes the place of its name at the top of the - With posters and descriptions on, a show's logo takes the place of its name at the top of the
page and on the library's banner, and each episode says how long it runs. A film says how long page and on the library's banner, and each episode says how long it runs. A film says how long
it runs in the line under its name. it runs in the line under its name.
+44
View File
@@ -99,6 +99,9 @@ struct TitleView: View {
.padding(.horizontal, side) .padding(.horizontal, side)
} }
} }
if !title.cast.isEmpty {
castRow(side: side)
}
// Under the episodes rather than over them: where to go // Under the episodes rather than over them: where to go
// once this one is done, not what the page is for. // once this one is done, not what the page is for.
if !moreLike.isEmpty { if !moreLike.isEmpty {
@@ -284,6 +287,14 @@ struct TitleView: View {
.padding(.top, 12) .padding(.top, 12)
.onTapGesture { overviewOpen.toggle() } .onTapGesture { overviewOpen.toggle() }
} }
// In the browser: the app itself never requests it.
if let trailer = title.trailerUrl.flatMap(URL.init(string:)) {
Button { openURL(trailer) } label: {
Label("Trailer", systemImage: "film")
}
.buttonStyle(.quiet)
.padding(.top, 4)
}
// Genres as chips, then the provider's tags quieter after them: a // Genres as chips, then the provider's tags quieter after them: a
// tag is its community describing it, not what it is filed as. // tag is its community describing it, not what it is filed as.
if !title.genres.isEmpty { if !title.genres.isEmpty {
@@ -344,6 +355,39 @@ struct TitleView: View {
return "Play \(upNext.label)" return "Play \(upNext.label)"
} }
/// The characters, each a round portrait over their name and who voices
/// them. A voice actor opens every title in the library they are in.
private func castRow(side: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Cast").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
.padding(.horizontal, side + 16)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(alignment: .top, spacing: 16) {
// By position: two characters can share a name.
ForEach(Array(title.cast.enumerated()), id: \.offset) { _, member in
VStack(spacing: 2) {
RemoteArtwork(member.characterImage ?? member.actorImage, member.character, labelled: false)
.frame(width: 76, height: 76)
.clipShape(Circle())
Text(member.character).textStyle(.labelMedium).foregroundStyle(scheme.onSurface)
.lineLimit(1).padding(.top, 4)
if let actor = member.actor {
Text(actor).textStyle(.labelSmall).foregroundStyle(scheme.onSurfaceVariant).lineLimit(1)
}
}
.frame(width: 88)
.contentShape(Rectangle())
.onTapGesture { if let actor = member.actor { onBrowse(.person(name: actor)) } }
.accessibilityElement(children: .combine)
.accessibilityHint(member.actor.map { "Browse \($0)" } ?? "")
}
}
.padding(.horizontal, side + 16)
}
}
.padding(.top, 16)
}
/// A row of covers: the franchise, with this title marked, or the titles /// A row of covers: the franchise, with this title marked, or the titles
/// like it. /// like it.
private func coverRow(_ heading: String, _ members: [TitleRecord], side: CGFloat) -> some View { private func coverRow(_ heading: String, _ members: [TitleRecord], side: CGFloat) -> some View {