Skip to main content

ravenrs_movie/
assistant.rs

1//! Someone types a question and the right moment of a training film opens.
2
3use std::collections::BTreeSet;
4
5use ravenrs_app_i18n::{Language, Texts};
6use ravenrs_layout::{Align, Axis, LayoutStyle};
7use ravenrs_reactive::State;
8use ravenrs_style::{Semantic, TextStyle};
9use ravenrs_widgets::{
10    BuildCx, Card, IconName, ListItem, LottieAnimation, NodeId, Paint, Placement, SegmentedControl, Select,
11    Text, TextField, VStack, View, ViewExt,
12};
13
14use crate::narration::Narration;
15use crate::player::{MovieCue, MovieLabels, MoviePlayer, clock_label};
16use crate::search::{Answer, Moment, MomentKind, MovieLibrary};
17
18/// How many answers it offers: more than this and it is a list to read, not an answer.
19const ANSWERS: usize = 4;
20const SPACING: f32 = 12.0;
21/// Below this width the names of the films do not fit side by side.
22const TABS_FROM: f32 = 860.0;
23const PICKER_WIDTH: f32 = 280.0;
24/// What goes around the film: the question, the choice of film and the controls.
25const AROUND_THE_FILM: f32 = 184.0;
26
27/// The words a [`MovieAssistant`] shows. English by default.
28#[derive(Clone, Debug)]
29#[non_exhaustive]
30#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
31pub struct AssistantLabels {
32    /// Inside the empty question field.
33    pub placeholder: String,
34    /// When no film answers the question.
35    pub nothing_found: String,
36    /// The name of the choice of film, for screen readers.
37    pub film: String,
38    /// The player's own words.
39    pub player: MovieLabels,
40}
41
42impl Default for AssistantLabels {
43    fn default() -> Self {
44        Self::from_texts(&Texts::new(Language::English))
45    }
46}
47
48#[cfg_attr(feature = "live", ravenrs_live::expose)]
49impl AssistantLabels {
50    /// The texts of a catalog, in its language. What it does not have comes from English.
51    pub fn from_texts(texts: &Texts) -> Self {
52        Self {
53            placeholder: texts.get("movie.assistant.placeholder"),
54            nothing_found: texts.get("movie.assistant.nothing_found"),
55            film: texts.get("movie.assistant.film"),
56            player: MovieLabels::from_texts(texts),
57        }
58    }
59
60    /// The words in Brazilian Portuguese.
61    pub fn pt_br() -> Self {
62        Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
63    }
64}
65
66/// A question field over the films of a [`MovieLibrary`]. Each answer is a moment
67/// of a film, and choosing one opens the film right there.
68///
69/// It looks only at the words the films hold, on this machine, with no model.
70///
71/// ```
72/// use ravenrs_movie::{MovieAssistant, MovieLibrary};
73///
74/// let assistant = MovieAssistant::new(MovieLibrary::new()).width(960.0);
75/// ```
76#[derive(Debug)]
77#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
78pub struct MovieAssistant {
79    library: MovieLibrary,
80    width: f32,
81    height: Option<f32>,
82    labels: AssistantLabels,
83    cue: Option<MovieCue>,
84    narration: Option<Narration>,
85    logo: Option<LottieAnimation>,
86}
87
88#[cfg_attr(feature = "live", ravenrs_live::expose)]
89impl MovieAssistant {
90    /// An assistant over `library`, with an empty question.
91    pub fn new(library: MovieLibrary) -> Self {
92        Self {
93            library,
94            width: 960.0,
95            height: None,
96            labels: AssistantLabels::default(),
97            cue: None,
98            narration: None,
99            logo: None,
100        }
101    }
102
103    /// The width of the whole assistant, in logical pixels (960 by default).
104    pub fn width(mut self, width: f32) -> Self {
105        self.width = width;
106        self
107    }
108
109    /// The height it should fit in: the film gets smaller rather than pushing its
110    /// controls out of view. With none, the film takes the whole width.
111    pub fn height(mut self, height: f32) -> Self {
112        self.height = Some(height);
113        self
114    }
115
116    /// Recordings of what the films say, in place of the system's voice.
117    #[must_use]
118    pub fn narration(mut self, narration: Narration) -> Self {
119        self.narration = Some(narration);
120        self
121    }
122
123    /// The app's animated logo, played on the films' opening and farewell cards.
124    #[must_use]
125    pub fn logo(mut self, logo: LottieAnimation) -> Self {
126        self.logo = Some(logo);
127        self
128    }
129
130    /// The words it shows.
131    pub fn labels(mut self, labels: AssistantLabels) -> Self {
132        self.labels = labels;
133        self
134    }
135
136    /// Lets something outside it, such as a [`MovieChat`](crate::MovieChat), open a
137    /// moment of the film it shows.
138    #[must_use]
139    pub fn cue(mut self, cue: MovieCue) -> Self {
140        self.cue = Some(cue);
141        self
142    }
143}
144
145/// Without the steps inside what it also found: "Pagar uma conta" and "Pague uma
146/// conta" read as the same thing twice. Only steps are kept when nothing else came up.
147pub(crate) fn whole_answers(answers: Vec<Answer>) -> Vec<Answer> {
148    let whole: Vec<Answer> =
149        answers.iter().filter(|answer| answer.moment.kind != MomentKind::Step).cloned().collect();
150    if whole.is_empty() { answers } else { whole }
151}
152
153/// A row that says which moment an answer is and opens it.
154pub(crate) fn moment_row(
155    library: &MovieLibrary,
156    answer: &Answer,
157    open: impl Fn(&Moment) + 'static,
158) -> ListItem {
159    let moment = answer.moment.clone();
160    let film = library.get(moment.film).map_or("", |(title, _)| title);
161    let when = clock_label(moment.at_ms);
162    let trailing = if library.len() > 1 { format!("{film} · {when}") } else { when };
163    let chosen = moment.clone();
164    ListItem::new(moment.title.clone())
165        .subtitle(moment.text.clone())
166        .icon(IconName::Play)
167        .trailing(trailing)
168        .on_click(move || open(&chosen))
169}
170
171/// The same in a narrow place, such as a chat: the name and where it is, one under the other.
172pub(crate) fn short_moment_row(
173    library: &MovieLibrary,
174    answer: &Answer,
175    open: impl Fn(&Moment) + 'static,
176) -> ListItem {
177    let moment = answer.moment.clone();
178    let film = library.get(moment.film).map_or("", |(title, _)| title);
179    let when = clock_label(moment.at_ms);
180    let chosen = moment.clone();
181    ListItem::new(moment.title.clone())
182        .subtitle(if library.len() > 1 { format!("{film} · {when}") } else { when })
183        .icon(IconName::Play)
184        .on_click(move || open(&chosen))
185}
186
187/// What choosing an answer does. The answers then step aside, so the film is in
188/// view, until the question changes.
189#[derive(Clone)]
190struct Open {
191    film: State<usize>,
192    question: State<String>,
193    answered: State<Option<String>>,
194    cue: MovieCue,
195}
196
197#[cfg_attr(feature = "live", ravenrs_live::expose)]
198impl Open {
199    fn moment(&self, moment: &Moment) {
200        self.film.set(moment.film);
201        self.answered.set(Some(self.question.get()));
202        self.cue.go_to(moment.at_ms);
203    }
204}
205
206impl View for MovieAssistant {
207    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
208        let question: State<String> = cx.use_state(String::new);
209        let film: State<usize> = cx.use_state(|| 0);
210        let answered: State<Option<String>> = cx.use_state(|| None);
211        let finished: State<BTreeSet<usize>> = cx.use_state(BTreeSet::new);
212        let own_cue = cx.use_state(MovieCue::new).get();
213        let cue = self.cue.clone().unwrap_or(own_cue);
214        if let Some(asked) = cue.take_film() {
215            film.set(asked);
216        }
217        let open = Open {
218            film: film.clone(),
219            question: question.clone(),
220            answered: answered.clone(),
221            cue: cue.clone(),
222        };
223
224        let asked = question.get();
225        let waiting = answered.get().as_ref() != Some(&asked);
226        let answers = if waiting { whole_answers(self.library.ask(&asked, ANSWERS)) } else { Vec::new() };
227        let (library, first) = (self.library.clone(), open.clone());
228        let field = TextField::new(question.binding())
229            .placeholder(self.labels.placeholder.clone())
230            .icon(IconName::Search)
231            .on_submit(move |text| {
232                if let Some(best) = library.ask(&text, 1).first() {
233                    first.moment(&best.moment);
234                }
235            });
236        let rows: Vec<ListItem> = answers
237            .iter()
238            .map(|answer| {
239                let open = open.clone();
240                moment_row(&self.library, answer, move |moment| open.moment(moment))
241            })
242            .collect();
243        let nothing = (waiting && !asked.trim().is_empty() && rows.is_empty()).then(|| {
244            Text::new(self.labels.nothing_found.clone())
245                .style(TextStyle::Callout)
246                .foreground(Semantic::InkMuted)
247        });
248        let field = cx.child(&field.width(self.width));
249        // The answers open over the film, which stays where it is.
250        if !rows.is_empty() || nothing.is_some() {
251            let answers = Card::new(
252                VStack::new((VStack::new(rows).spacing(2.0).align(Align::Stretch), nothing))
253                    .align(Align::Stretch),
254            )
255            .padding(8.0)
256            .width(self.width);
257            let (close, asked) = (answered.clone(), asked.clone());
258            let dismiss = std::rc::Rc::new(move || close.set(Some(asked.clone())));
259            cx.overlay(field, Placement::Below, &answers, Some(dismiss));
260        }
261        let mut children = vec![field];
262
263        if self.library.len() > 1 {
264            // What was already watched is marked, to show how far someone has come.
265            let seen = finished.get();
266            let titles: Vec<String> = (0..self.library.len())
267                .filter_map(|i| self.library.get(i).map(|f| (i, f.0)))
268                .map(|(i, title)| if seen.contains(&i) { format!("✓ {title}") } else { title.to_owned() })
269                .collect();
270            children.push(if self.width >= TABS_FROM {
271                cx.child(&SegmentedControl::new(film.binding(), titles).label(self.labels.film.clone()))
272            } else {
273                cx.child(
274                    &Select::new(film.binding(), titles)
275                        .label(self.labels.film.clone())
276                        .width(PICKER_WIDTH.min(self.width)),
277                )
278            });
279        }
280        let shown = film.get();
281        if let Some((_, movie)) = self.library.get(shown) {
282            let mut player =
283                MoviePlayer::new(movie.clone()).width(self.width).labels(self.labels.player.clone()).cue(cue);
284            if let Some(height) = self.height {
285                player = player.max_film_height(height - AROUND_THE_FILM);
286            }
287            if let Some(logo) = &self.logo {
288                player = player.logo(logo.clone());
289            }
290            if let Some(narration) = &self.narration {
291                player = player.narration(narration.clone());
292            }
293            let mark = finished.clone();
294            player = player.on_finished(move || {
295                mark.update(|seen| {
296                    seen.insert(shown);
297                });
298            });
299            if let Some((title, _)) = self.library.get(shown + 1) {
300                let go = film.clone();
301                player = player.next(title, move || go.set(shown + 1));
302            }
303            // Each film keeps its own place.
304            children.push(cx.keyed_child(shown as u64, &player));
305        }
306        cx.container(
307            LayoutStyle { spacing: SPACING, ..LayoutStyle::stack(Axis::Vertical) },
308            &children,
309            Paint::None,
310        )
311    }
312}