diff --git a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt index 852b2a7..ecfe118 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/TitleScreen.kt @@ -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 diff --git a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt index e3c96a7..0fc113c 100644 --- a/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt +++ b/android/app/src/test/java/io/narl/protonstream/ui/ScreenshotTest.kt @@ -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. */ diff --git a/android/app/src/test/screenshots/title-phone-light.png b/android/app/src/test/screenshots/title-phone-light.png index 953317a..bc7788d 100644 Binary files a/android/app/src/test/screenshots/title-phone-light.png and b/android/app/src/test/screenshots/title-phone-light.png differ diff --git a/android/app/src/test/screenshots/title-phone.png b/android/app/src/test/screenshots/title-phone.png index 82e0218..9f44ed1 100644 Binary files a/android/app/src/test/screenshots/title-phone.png and b/android/app/src/test/screenshots/title-phone.png differ diff --git a/android/app/src/test/screenshots/title-tablet.png b/android/app/src/test/screenshots/title-tablet.png index 998d9b4..0fac718 100644 Binary files a/android/app/src/test/screenshots/title-tablet.png and b/android/app/src/test/screenshots/title-tablet.png differ diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 3192e7b..c2e82ce 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -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, + /// The characters, main ones first, with who voices them. + #[uniffi(default = [])] + pub cast: Vec, + /// The trailer's page, to open in the browser; see + /// `pstr_core::metadata::Trailer::url`. + #[uniffi(default = None)] + pub trailer_url: Option, +} + +/// 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, + pub actor: Option, + pub actor_image: Option, } /// 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), diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs index 10a8fbc..cfc7d28 100644 --- a/crates/pstr-app/src/ui/mod.rs +++ b/crates/pstr-app/src/ui/mod.rs @@ -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 { + 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) diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs index 76ecb31..2d9abcd 100644 --- a/crates/pstr-app/src/ui/title.rs +++ b/crates/pstr-app/src/ui/title.rs @@ -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, +) { + 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,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.hyperlink_to( - ui::muted(format!("More on {}", found.provider.label())), - url, - ); + 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, + ); + } + }); } } diff --git a/crates/pstr-core/src/discover.rs b/crates/pstr-core/src/discover.rs index 7f0ff0d..e3f1937 100644 --- a/crates/pstr-core/src/discover.rs +++ b/crates/pstr-core/src/discover.rs @@ -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 = 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(); diff --git a/crates/pstr-core/src/metadata.rs b/crates/pstr-core/src/metadata.rs index 1750cd9..432fd61 100644 --- a/crates/pstr-core/src/metadata.rs +++ b/crates/pstr-core/src/metadata.rs @@ -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, + /// The main characters and then the supporting ones, each with who voices + /// them in the original language. + pub cast: Vec, + /// Where the provider says its trailer is. + pub trailer: Option, /// 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, } +/// 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, + /// `MAIN`, `SUPPORTING` or `BACKGROUND`. + pub role: Option, + /// The voice actor in the original language. + pub actor: Option, + pub actor_image: Option, +} + +/// 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 { + 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| { diff --git a/crates/pstr-meta/src/anilist.rs b/crates/pstr-meta/src/anilist.rs index eed9784..38314e2 100644 --- a/crates/pstr-meta/src/anilist.rs +++ b/crates/pstr-meta/src/anilist.rs @@ -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, studios: Option, staff: Option, + characters: Option, + trailer: Option, #[serde(rename = "nextAiringEpisode")] next_airing_episode: Option, relations: Option, @@ -636,6 +649,32 @@ struct StaffEdge { #[derive(Deserialize)] struct Person { name: PersonName, + image: Option, +} + +#[derive(Deserialize)] +struct Image { + medium: Option, +} + +#[derive(Deserialize)] +struct Characters { + #[serde(default)] + edges: Vec, +} + +#[derive(Deserialize)] +struct CharacterEdge { + role: Option, + node: Option, + #[serde(rename = "voiceActors", default)] + voice_actors: Vec, +} + +#[derive(Deserialize)] +struct AniTrailer { + id: Option, + site: Option, } #[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()), diff --git a/docs/METADATA.md b/docs/METADATA.md index 8b2b70f..8beea5a 100644 --- a/docs/METADATA.md +++ b/docs/METADATA.md @@ -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 diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 2d9d334..a8793ae 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -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 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f5bad4b..1291a46 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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. diff --git a/ios/ProtonStream/UI/TitleView.swift b/ios/ProtonStream/UI/TitleView.swift index b3cf25f..4a6c7fd 100644 --- a/ios/ProtonStream/UI/TitleView.swift +++ b/ios/ProtonStream/UI/TitleView.swift @@ -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 {