mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
87ccaf26e1
commit
7bfebc5fd5
15 files changed
+535
-15
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 |
@@ -545,7 +545,7 @@ pub fn airing_countdown(episode: u32, at: i64, now: i64) -> String {
|
||||
}
|
||||
|
||||
/// 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]
|
||||
pub fn facet_kind(facet: BrowseFacet) -> String {
|
||||
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`.
|
||||
#[uniffi(default = None)]
|
||||
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.
|
||||
@@ -3893,6 +3910,21 @@ fn title_record(
|
||||
next_airing_at: None,
|
||||
on_list: false,
|
||||
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(),
|
||||
logo_url: metadata.and_then(|found| found.logo_url.clone()),
|
||||
runtime: details.and_then(|details| details.runtime),
|
||||
|
||||
@@ -81,6 +81,17 @@ impl Art<'_> {
|
||||
.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`].
|
||||
pub fn name<'t>(&'t self, title: &'t Title) -> &'t str {
|
||||
title_name(title, self.metadata, self.names)
|
||||
|
||||
@@ -151,6 +151,20 @@ pub fn show(
|
||||
}
|
||||
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
|
||||
// once this one is done, not what the page is for.
|
||||
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
|
||||
/// `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.
|
||||
@@ -739,13 +842,29 @@ 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.horizontal(|ui| {
|
||||
// In the browser: the app itself never requests the trailer.
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The grey line under the name: what the library knows, plus what the provider
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::shelves::Shelf;
|
||||
pub enum Facet {
|
||||
Genre(String),
|
||||
Studio(String),
|
||||
/// A director.
|
||||
/// A director, or a voice actor in the cast.
|
||||
Person(String),
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ impl Facet {
|
||||
match self {
|
||||
Self::Genre(_) => "Genre",
|
||||
Self::Studio(_) => "Studio",
|
||||
Self::Person(_) => "Director",
|
||||
Self::Person(_) => "Person",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,13 @@ impl Facet {
|
||||
Self::Studio(name) => {
|
||||
details.is_some_and(|details| details.studios.iter().any(|studio| studio == name))
|
||||
}
|
||||
Self::Person(name) => {
|
||||
details.is_some_and(|details| details.directors.iter().any(|person| person == name))
|
||||
}
|
||||
Self::Person(name) => details.is_some_and(|details| {
|
||||
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 {
|
||||
continue;
|
||||
};
|
||||
let made = details
|
||||
// One count per title, however many characters a voice actor has
|
||||
// in it.
|
||||
let made: HashSet<Facet> = details
|
||||
.directors
|
||||
.iter()
|
||||
.chain(
|
||||
details
|
||||
.cast
|
||||
.iter()
|
||||
.filter_map(|member| member.actor.as_ref()),
|
||||
)
|
||||
.map(|name| Facet::Person(name.clone()))
|
||||
.chain(
|
||||
details
|
||||
.studios
|
||||
.iter()
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -570,7 +584,7 @@ mod tests {
|
||||
3,
|
||||
"both Frieren titles and Bocchi"
|
||||
);
|
||||
assert_eq!(person.kind(), "Director");
|
||||
assert_eq!(person.kind(), "Person");
|
||||
let genre = Facet::Genre("Comedy".into());
|
||||
let mut comedy = genre.titles(&library.titles, &metadata);
|
||||
comedy.sort();
|
||||
|
||||
@@ -180,6 +180,11 @@ pub struct TitleDetails {
|
||||
/// Minutes: an episode's length for a series, the whole running time for
|
||||
/// a film.
|
||||
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
|
||||
/// 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
|
||||
@@ -193,12 +198,56 @@ pub struct TitleDetails {
|
||||
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 {
|
||||
/// What a provider that fills these in today writes into
|
||||
/// [`TitleDetails::revision`]. Raise it with a field that existing
|
||||
/// records should be fetched again for. `1` added
|
||||
/// [`TitleDetails::runtime`].
|
||||
pub const REVISION: u32 = 1;
|
||||
/// [`TitleDetails::runtime`], `2` [`TitleDetails::cast`] and
|
||||
/// [`TitleDetails::trailer`].
|
||||
pub const REVISION: u32 = 2;
|
||||
|
||||
/// Whether these were written by this build's [`TitleDetails::REVISION`]
|
||||
/// or a later one.
|
||||
@@ -603,6 +652,39 @@ impl Default for MetadataConfig {
|
||||
mod tests {
|
||||
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]
|
||||
fn a_status_is_said_for_a_person_and_an_unknown_one_is_not_said_at_all() {
|
||||
let status = |value: &str| {
|
||||
|
||||
@@ -18,7 +18,8 @@ use std::collections::HashSet;
|
||||
|
||||
use pstr_core::library::{self, TitleKind};
|
||||
use pstr_core::metadata::{
|
||||
Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
|
||||
CastMember, Enrichment, EpisodeGuide, EpisodeMetadata, ProviderId, TitleDetails, TitleMetadata,
|
||||
Trailer,
|
||||
};
|
||||
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.
|
||||
///
|
||||
/// 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 {
|
||||
() => {
|
||||
r#"
|
||||
@@ -84,6 +87,14 @@ fragment entry on Media {
|
||||
siteUrl
|
||||
studios(isMain: true) { nodes { name } }
|
||||
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 }
|
||||
relations { edges { relationType node { id type } } }
|
||||
}
|
||||
@@ -591,6 +602,8 @@ struct Media {
|
||||
site_url: Option<String>,
|
||||
studios: Option<Studios>,
|
||||
staff: Option<Staff>,
|
||||
characters: Option<Characters>,
|
||||
trailer: Option<AniTrailer>,
|
||||
#[serde(rename = "nextAiringEpisode")]
|
||||
next_airing_episode: Option<NextAiring>,
|
||||
relations: Option<Relations>,
|
||||
@@ -636,6 +649,32 @@ struct StaffEdge {
|
||||
#[derive(Deserialize)]
|
||||
struct Person {
|
||||
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)]
|
||||
@@ -759,6 +798,35 @@ impl Media {
|
||||
popularity: self.popularity,
|
||||
status: self.status,
|
||||
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,
|
||||
next_episode: self
|
||||
.next_airing_episode
|
||||
@@ -973,6 +1041,51 @@ mod tests {
|
||||
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 {
|
||||
RelationEdge {
|
||||
relation_type: Some(relation.into()),
|
||||
|
||||
@@ -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.
|
||||
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`
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
- **The part of a share link after `#`** is the key that decrypts the share. The app treats it as
|
||||
|
||||
@@ -114,6 +114,12 @@ top.
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -99,6 +99,9 @@ struct TitleView: View {
|
||||
.padding(.horizontal, side)
|
||||
}
|
||||
}
|
||||
if !title.cast.isEmpty {
|
||||
castRow(side: side)
|
||||
}
|
||||
// Under the episodes rather than over them: where to go
|
||||
// once this one is done, not what the page is for.
|
||||
if !moreLike.isEmpty {
|
||||
@@ -284,6 +287,14 @@ struct TitleView: View {
|
||||
.padding(.top, 12)
|
||||
.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
|
||||
// tag is its community describing it, not what it is filed as.
|
||||
if !title.genres.isEmpty {
|
||||
@@ -344,6 +355,39 @@ struct TitleView: View {
|
||||
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
|
||||
/// like it.
|
||||
private func coverRow(_ heading: String, _ members: [TitleRecord], side: CGFloat) -> some View {
|
||||
|
||||
Reference in new issue
Block a user