Skip to main content

ravenrs_movie/
player.rs

1//! Plays a film, stops where it teaches, and lets the watcher jump around.
2
3use std::cell::Cell;
4use std::rc::Rc;
5
6use ravenrs_app_i18n::{Language, Texts};
7use ravenrs_core::{Border, CursorIcon, Key, Role};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle, Weight};
11use ravenrs_widgets::{
12    BuildCx, Button, ButtonSize, ButtonStyle, Card, HStack, Icon, IconButton, IconName, List, ListItem,
13    LottieAnimation, NodeId, Paint, QuadPaint, ScrollView, Semantics, Slider, Sound, SoundClip, Text, Toggle,
14    VStack, View, ViewExt,
15};
16
17use crate::composition::{Composition, camera_at};
18use crate::movie::{FilmSound, Hotspot, HotspotKind, Movie};
19use crate::narration::Narration;
20use crate::project::{CardLook, ClipKind, Place, Region};
21
22/// The film offered at the end: its name and what opening it does.
23type NextFilm = (String, Rc<dyn Fn()>);
24
25/// How often it draws while playing, in seconds. The pointer and the zooms have
26/// no frames of their own, so they move at this pace.
27const FRAME: f64 = 1.0 / 30.0;
28/// How fast a film can play.
29const SPEEDS: [f64; 3] = [1.0, 1.25, 1.5];
30const SPEED_NAMES: [&str; 3] = ["1×", "1,25×", "1,5×"];
31/// How far the arrow keys jump.
32const ARROW_JUMP_MS: u64 = 5_000;
33const CHAPTERS_WIDTH: f32 = 260.0;
34/// A film is never smaller than this, however little height there is.
35const SMALLEST_FILM_WIDTH: f32 = 320.0;
36/// The title over the chapters and the gap under it.
37const CHAPTERS_TITLE: f32 = 28.0;
38const COLUMN_GAP: f32 = 16.0;
39/// Below this total width, the chapters go under the film.
40const SIDE_BY_SIDE_FROM: f32 = 720.0;
41/// The box that explains what is being done, and the film width it needs to fit:
42/// on a smaller film the same words go under it.
43const BOX_WIDTH: f32 = 340.0;
44const BOX_FROM: f32 = 480.0;
45const POSTER_PLAY_AT: f32 = 0.66;
46const POSTER_PLAY_SIZE: f32 = 32.0;
47const BOX_GAP: f32 = 12.0;
48/// What a box takes besides its lines of text, and a line of its text.
49const BOX_FRAME_HEIGHT: f32 = 84.0;
50const BOX_LINE_HEIGHT: f32 = 20.0;
51const BOX_LETTERS_PER_LINE: usize = 42;
52/// A frame that came late plays only what is this recent: a burst of clicks
53/// after a pause would tell nothing.
54const SOUNDS_AT_MOST_MS: u64 = 400;
55
56/// The words a [`MoviePlayer`] shows. English by default.
57#[derive(Clone, Debug)]
58#[non_exhaustive]
59#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
60pub struct MovieLabels {
61    /// The play button.
62    pub play: String,
63    /// The pause button.
64    pub pause: String,
65    /// Goes back to the beginning.
66    pub restart: String,
67    /// Stops the film at each step that asks for a click.
68    pub guided: String,
69    /// Goes past a step without clicking on the film.
70    pub next_step: String,
71    /// The heading of the chapters.
72    pub chapters: String,
73    /// The film's position, for screen readers.
74    pub position: String,
75    /// The film itself, for screen readers.
76    pub movie: String,
77    /// Turns on the sound of clicks and keys.
78    pub sound: String,
79    /// Turns on the voice that reads what the film says.
80    pub narration: String,
81    /// The language of the voice, such as `pt-BR`.
82    pub language: String,
83    /// Which step of how many, where `{}` goes for each number.
84    pub step_of: String,
85    /// What the end of a film says.
86    pub finished: String,
87    /// Plays a finished film again.
88    pub watch_again: String,
89    /// Goes to the next film, whose name goes where `{}` is.
90    pub next_film: String,
91}
92
93impl Default for MovieLabels {
94    fn default() -> Self {
95        Self::from_texts(&Texts::new(Language::English))
96    }
97}
98
99#[cfg_attr(feature = "live", ravenrs_live::expose)]
100impl MovieLabels {
101    /// The texts of a catalog, in its language. What it does not have comes from English.
102    pub fn from_texts(texts: &Texts) -> Self {
103        Self {
104            play: texts.get("movie.player.play"),
105            pause: texts.get("movie.player.pause"),
106            restart: texts.get("movie.player.restart"),
107            guided: texts.get("movie.player.guided"),
108            next_step: texts.get("movie.player.next_step"),
109            chapters: texts.get("movie.player.chapters"),
110            position: texts.get("movie.player.position"),
111            movie: texts.get("movie.player.movie"),
112            sound: texts.get("movie.player.sound"),
113            narration: texts.get("movie.player.narration"),
114            language: texts.get("movie.player.language"),
115            step_of: texts.get("movie.player.step_of"),
116            finished: texts.get("movie.player.finished"),
117            watch_again: texts.get("movie.player.watch_again"),
118            next_film: texts.get("movie.player.next_film"),
119        }
120    }
121
122    /// The words in Brazilian Portuguese.
123    pub fn pt_br() -> Self {
124        Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
125    }
126}
127
128/// `2:05`.
129pub(crate) fn clock_label(ms: u64) -> String {
130    let seconds = ms / 1000;
131    format!("{}:{:02}", seconds / 60, seconds % 60)
132}
133
134/// Tells a [`MoviePlayer`] to open a moment of its film, from outside it, as
135/// [`MovieAssistant`](crate::MovieAssistant) does. Cloning shares it.
136///
137/// ```
138/// use ravenrs_movie::MovieCue;
139///
140/// let cue = MovieCue::new();
141/// cue.go_to(61_500);
142/// ```
143#[derive(Clone, Debug)]
144#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
145pub struct MovieCue {
146    request: Rc<Cell<Option<u64>>>,
147    film: Rc<Cell<Option<usize>>>,
148    changes: State<u64>,
149}
150
151#[cfg_attr(feature = "live", ravenrs_live::expose)]
152impl MovieCue {
153    /// A cue that asks for nothing yet.
154    pub fn new() -> Self {
155        Self { request: Rc::new(Cell::new(None)), film: Rc::new(Cell::new(None)), changes: State::new(0) }
156    }
157
158    /// Opens the film at `ms` of its timeline. It plays from there, unless a step
159    /// that waits for a click is exactly there: then it waits at that step.
160    pub fn go_to(&self, ms: u64) {
161        self.request.set(Some(ms));
162        self.changes.update(|n| *n += 1);
163    }
164
165    /// Opens a film of a [`MovieAssistant`](crate::MovieAssistant)'s library at `ms`.
166    pub fn go_to_film(&self, film: usize, ms: u64) {
167        self.film.set(Some(film));
168        self.go_to(ms);
169    }
170
171    /// The film someone asked for, once.
172    pub(crate) fn take_film(&self) -> Option<usize> {
173        let _ = self.changes.get();
174        self.film.take()
175    }
176}
177
178impl Default for MovieCue {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184/// Where a film is, read and written while building. Clicks raise `changes`, so
185/// the player rebuilds; while playing, it asks to be woken for the next picture.
186#[derive(Debug, Default)]
187struct Playhead {
188    position_ms: Cell<u64>,
189    /// Playing since this clock reading, from this position.
190    running: Cell<Option<(f64, u64)>>,
191    /// The stop it is waiting at.
192    waiting: Cell<Option<usize>>,
193    /// The note someone clicked, shown until the film moves on.
194    opened: Cell<Option<usize>>,
195    /// What the voice last started saying, so that it says each thing once.
196    spoken: Cell<Option<usize>>,
197    /// The film jumped: what the voice was saying no longer applies.
198    jumped: Cell<bool>,
199    /// Which of the speeds it plays at.
200    speed: Cell<usize>,
201}
202
203#[cfg_attr(feature = "live", ravenrs_live::expose)]
204impl Playhead {
205    fn is_running(&self) -> bool {
206        self.running.get().is_some()
207    }
208
209    fn pause(&self) {
210        self.running.set(None);
211    }
212
213    /// Goes on from the step it is waiting at, past the time the step would take
214    /// for whoever is not stopping at steps.
215    fn go_on(&self, movie: &Movie) {
216        if let Some(spot) = self.waiting.get().and_then(|i| movie.hotspots().get(i)) {
217            self.position_ms.set(spot.until_ms.min(movie.duration_ms()));
218        }
219        self.play(movie);
220    }
221
222    fn seek(&self, ms: u64) {
223        self.position_ms.set(ms);
224        self.waiting.set(None);
225        self.opened.set(None);
226        self.jumped.set(true);
227        if self.is_running() {
228            self.running.set(Some((f64::NAN, ms)));
229        }
230    }
231
232    /// Goes to the next speed, from where it is.
233    fn next_speed(&self) {
234        self.speed.set((self.speed.get() + 1) % SPEEDS.len());
235        if self.is_running() {
236            self.running.set(Some((f64::NAN, self.position_ms.get())));
237        }
238    }
239
240    fn play(&self, movie: &Movie) {
241        // At the end, playing means playing again.
242        if self.position_ms.get() >= movie.duration_ms() {
243            self.position_ms.set(0);
244        }
245        self.waiting.set(None);
246        self.opened.set(None);
247        self.running.set(Some((f64::NAN, self.position_ms.get())));
248    }
249
250    /// Opens a moment someone asked for, waiting there if it is a step.
251    fn open(&self, movie: &Movie, ms: u64, guided: bool) {
252        self.seek(ms);
253        let stop = guided
254            .then(|| movie.hotspots().iter().position(|s| s.kind == HotspotKind::Stop && s.from_ms == ms))
255            .flatten();
256        if stop.is_some() {
257            self.running.set(None);
258            self.waiting.set(stop);
259        } else {
260            self.play(movie);
261        }
262    }
263
264    /// Moves to where the clock says it should be. The first stop crossed on the
265    /// way holds it there, and the end of the film finishes it.
266    fn advance(&self, movie: &Movie, guided: bool, now: f64) {
267        let Some((since, from)) = self.running.get() else {
268            return;
269        };
270        // A click has no clock, so playing starts counting from the next build.
271        if since.is_nan() {
272            self.running.set(Some((now, from)));
273            return;
274        }
275        let speed = SPEEDS[self.speed.get().min(SPEEDS.len() - 1)];
276        let target = from + ((now - since).max(0.0) * 1000.0 * speed) as u64;
277        let before = self.position_ms.get();
278        let stop = guided
279            .then(|| {
280                movie.hotspots().iter().position(|spot| {
281                    spot.kind == HotspotKind::Stop && spot.from_ms > before && spot.from_ms <= target
282                })
283            })
284            .flatten();
285        if let Some(stop) = stop {
286            self.position_ms.set(movie.hotspots()[stop].from_ms);
287            self.waiting.set(Some(stop));
288            self.running.set(None);
289        } else if target >= movie.duration_ms() {
290            self.position_ms.set(movie.duration_ms());
291            self.running.set(None);
292        } else {
293            self.position_ms.set(target);
294        }
295    }
296}
297
298/// Stops the voice when the player leaves the screen.
299struct Narrator(Sound);
300
301impl Drop for Narrator {
302    fn drop(&mut self) {
303        self.0.stop_speaking();
304    }
305}
306
307/// The sounds a film makes, computed once for each player.
308struct FilmSounds {
309    click: SoundClip,
310    key: SoundClip,
311}
312
313/// Where the box about `spot` goes on a film `area` big: under what it marks, or
314/// above, or beside it, and always whole. A box about the whole screen is centered.
315fn box_place(spot: Option<ravenrs_core::Rect>, area: ravenrs_core::Size, height: f32) -> (f32, f32) {
316    let width = BOX_WIDTH;
317    let inside = |x: f32, y: f32| {
318        (
319            x.clamp(BOX_GAP, (area.width - width - BOX_GAP).max(BOX_GAP)),
320            y.clamp(BOX_GAP, (area.height - height - BOX_GAP).max(BOX_GAP)),
321        )
322    };
323    let Some(spot) = spot else {
324        return inside((area.width - width) / 2.0, (area.height - height) / 2.0);
325    };
326    let middle = spot.x() + spot.width() / 2.0 - width / 2.0;
327    if spot.y() + spot.height() + BOX_GAP + height <= area.height {
328        return inside(middle, spot.y() + spot.height() + BOX_GAP);
329    }
330    if spot.y() - BOX_GAP - height >= 0.0 {
331        return inside(middle, spot.y() - BOX_GAP - height);
332    }
333    if spot.x() + spot.width() + BOX_GAP + width <= area.width {
334        return inside(spot.x() + spot.width() + BOX_GAP, spot.y());
335    }
336    inside(spot.x() - BOX_GAP - width, spot.y())
337}
338
339/// The mark over a spot on the film.
340struct HotspotMark {
341    hotspot: Hotspot,
342    frame: ravenrs_core::Rect,
343    waiting: bool,
344    on_click: Rc<dyn Fn()>,
345}
346
347impl View for HotspotMark {
348    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
349        let theme = *cx.theme();
350        let color = theme.color(Semantic::Accent);
351        let hovered = cx.is_hovered();
352        let node = cx.container(
353            LayoutStyle {
354                absolute: Some((self.frame.x(), self.frame.y())),
355                width: Some(self.frame.width()),
356                height: Some(self.frame.height()),
357                ..LayoutStyle::stack(Axis::Vertical)
358            },
359            &[],
360            Paint::Quad(QuadPaint {
361                fill: color.faded(if self.waiting || hovered { 0.28 } else { 0.14 }).into(),
362                corner_radius: theme.radii.md,
363                border: Some(Border { width: 2.0, color }),
364                shadow: None,
365            }),
366        );
367        cx.semantics(
368            node,
369            Semantics::new(Role::Button)
370                .name(self.hotspot.title.clone())
371                .description(self.hotspot.text.clone()),
372        );
373        cx.on_click(node, self.on_click.clone());
374        cx.focusable(node, theme.radii.md);
375        cx.cursor(node, CursorIcon::Pointer);
376        node
377    }
378}
379
380/// "Step 2 of 5": where a step stands among those of its chapter.
381fn step_in_chapter(movie: &Movie, spot: &Hotspot, words: &str) -> Option<String> {
382    if spot.kind != HotspotKind::Stop {
383        return None;
384    }
385    let chapter = movie.chapter_at(spot.from_ms);
386    let stops: Vec<&Hotspot> = movie
387        .hotspots()
388        .iter()
389        .filter(|other| other.kind == HotspotKind::Stop && movie.chapter_at(other.from_ms) == chapter)
390        .collect();
391    let place = stops.iter().position(|other| other.from_ms == spot.from_ms)?;
392    Some(words.replacen("{}", &(place + 1).to_string(), 1).replacen("{}", &stops.len().to_string(), 1))
393}
394
395/// How tall a film `width` wide is.
396fn height_of(width: f32, logical: ravenrs_core::Size) -> f32 {
397    width * logical.height / logical.width.max(1.0)
398}
399
400/// The film and the marks over it.
401struct Stage<'a> {
402    movie: &'a Movie,
403    playhead: Rc<Playhead>,
404    width: f32,
405    marks: Vec<Hotspot>,
406    changes: State<u64>,
407    labels: &'a MovieLabels,
408    /// The explanation over the film, and whether it waits for a click.
409    explaining: Option<Hotspot>,
410    next: Option<&'a NextFilm>,
411    logo: Option<LottieAnimation>,
412}
413
414impl View for Stage<'_> {
415    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
416        let mut at = self.playhead.position_ms.get();
417        // Nothing has played yet: the first thing seen should not be a black screen.
418        if at == 0 && !self.playhead.is_running() {
419            at = self.movie.poster_ms();
420        }
421        let logical = self.movie.logical_size();
422        let height = height_of(self.width, logical);
423        let camera = camera_at(self.movie, at, self.width, false);
424        let film = Composition::new(self.movie.clone(), at).width(self.width).logo(self.logo.clone());
425        let film = cx.child(&film);
426        cx.semantics(film, Semantics::new(Role::Image).name(self.labels.movie.clone()));
427        let mut children = vec![film];
428        for hotspot in self.marks.iter().filter(|hotspot| !hotspot.is_general()) {
429            let waiting = hotspot.kind == HotspotKind::Stop;
430            let index = self.movie.hotspots().iter().position(|spot| spot == hotspot);
431            let (playhead, changes, movie) =
432                (self.playhead.clone(), self.changes.clone(), self.movie.clone());
433            let on_click: Rc<dyn Fn()> = Rc::new(move || {
434                if waiting {
435                    if playhead.waiting.get().is_some() {
436                        playhead.go_on(&movie);
437                    }
438                } else {
439                    playhead.opened.set(index);
440                }
441                changes.update(|n| *n += 1);
442            });
443            let region = Region::new(hotspot.x, hotspot.y, hotspot.width, hotspot.height);
444            let mark =
445                HotspotMark { hotspot: hotspot.clone(), frame: camera.region(region), waiting, on_click };
446            children.push(cx.child(&mark));
447        }
448        // Its end says so, and offers what comes next.
449        if at >= self.movie.duration_ms() && !self.playhead.is_running() && self.movie.duration_ms() > 0 {
450            let (playhead, changes, movie) =
451                (self.playhead.clone(), self.changes.clone(), self.movie.clone());
452            let again = Button::new(self.labels.watch_again.clone())
453                .icon(IconName::RotateCcw)
454                .style(ButtonStyle::Secondary)
455                .on_click(move || {
456                    playhead.play(&movie);
457                    changes.update(|n| *n += 1);
458                });
459            let next = self.next.map(|(title, open)| {
460                let open = open.clone();
461                Button::new(self.labels.next_film.replacen("{}", title, 1))
462                    .trailing_icon(IconName::ArrowRight)
463                    .on_click(move || open())
464            });
465            let content = VStack::new((
466                Text::new(self.labels.finished.clone()).style(TextStyle::Headline),
467                HStack::new((again, next)).spacing(8.0).wrap(),
468            ))
469            .spacing(12.0)
470            .align(Align::Center);
471            let card = cx.child(&Card::new(content));
472            children.push(cx.container(
473                LayoutStyle {
474                    absolute: Some((0.0, 0.0)),
475                    width: Some(self.width),
476                    height: Some(height),
477                    align: Align::Center,
478                    justify: ravenrs_layout::Justify::Center,
479                    ..LayoutStyle::stack(Axis::Vertical)
480                },
481                &[card],
482                Paint::Quad(QuadPaint::new(cx.theme().color(Semantic::Scrim))),
483            ));
484        }
485        // A film that has not started says that it is one.
486        if self.playhead.position_ms.get() == 0 && !self.playhead.is_running() {
487            let (playhead, changes, movie) =
488                (self.playhead.clone(), self.changes.clone(), self.movie.clone());
489            let play = cx.child(
490                &IconButton::new(IconName::Play)
491                    .label(self.labels.play.clone())
492                    .style(ButtonStyle::Secondary)
493                    .on_click(move || {
494                        playhead.play(&movie);
495                        changes.update(|n| *n += 1);
496                    }),
497            );
498            children.push(cx.container(
499                LayoutStyle {
500                    absolute: Some(((self.width - POSTER_PLAY_SIZE) / 2.0, height * POSTER_PLAY_AT)),
501                    ..LayoutStyle::stack(Axis::Vertical)
502                },
503                &[play],
504                Paint::None,
505            ));
506        }
507        if let Some(spot) = &self.explaining {
508            let chapter = self.movie.chapter_at(at).and_then(|i| self.movie.chapters().get(i));
509            let lines = spot.text.chars().count().div_ceil(BOX_LETTERS_PER_LINE).max(1) as f32;
510            let waiting = self.playhead.waiting.get().is_some();
511            let height = BOX_FRAME_HEIGHT + lines * BOX_LINE_HEIGHT + if waiting { 36.0 } else { 0.0 };
512            let target = (!spot.is_general())
513                .then(|| camera.region(Region::new(spot.x, spot.y, spot.width, spot.height)));
514            let (x, y) = box_place(
515                target,
516                ravenrs_core::Size::new(self.width, height_of(self.width, logical)),
517                height,
518            );
519            let step = step_in_chapter(self.movie, spot, &self.labels.step_of);
520            let eyebrow = step
521                .or_else(|| chapter.filter(|chapter| chapter.title != spot.title).map(|c| c.title.clone()))
522                .map(|said| Text::new(said).style(TextStyle::Caption).foreground(Semantic::Accent));
523            let go_on = waiting.then(|| {
524                let (playhead, changes, movie) =
525                    (self.playhead.clone(), self.changes.clone(), self.movie.clone());
526                Button::new(self.labels.next_step.clone())
527                    .style(ButtonStyle::Secondary)
528                    .size(ButtonSize::Small)
529                    .icon(IconName::ArrowRight)
530                    .on_click(move || {
531                        playhead.go_on(&movie);
532                        changes.update(|n| *n += 1);
533                    })
534            });
535            let content = VStack::new((
536                eyebrow,
537                Text::new(spot.title.clone()).font_weight(Weight::SEMIBOLD),
538                Text::new(spot.text.clone()).style(TextStyle::Callout).foreground(Semantic::InkMuted),
539                go_on,
540            ))
541            .spacing(4.0)
542            .align(Align::Start);
543            let card = cx.child(&Card::new(content).padding(16.0).width(BOX_WIDTH));
544            children.push(cx.container(
545                LayoutStyle {
546                    absolute: Some((x, y)),
547                    width: Some(BOX_WIDTH),
548                    ..LayoutStyle::stack(Axis::Vertical)
549                },
550                &[card],
551                Paint::None,
552            ));
553        }
554        let node = cx.container(
555            LayoutStyle {
556                width: Some(self.width),
557                height: Some(height),
558                overflow_hidden: true,
559                ..LayoutStyle::stack(Axis::Vertical)
560            },
561            &children,
562            Paint::None,
563        );
564        cx.clip(node);
565        node
566    }
567}
568
569/// Plays a [`Movie`] as an interactive manual.
570///
571/// Whoever watches can pause, drag the position, jump to a chapter and click the
572/// marks on the film. With "stop at each step" on, which is how it starts, the
573/// film holds at each [`Recorder::guide`](crate::Recorder::guide) until the marked spot is clicked,
574/// so people follow it at their own pace. It sleeps while paused.
575///
576/// Cloning a film is cheap, since the film itself is shared:
577///
578/// ```no_run
579/// use ravenrs_movie::{Movie, MoviePlayer};
580///
581/// let movie = Movie::from_bytes(&std::fs::read("manual.ravenmovie").unwrap_or_default())?;
582/// let player = MoviePlayer::new(movie).width(960.0);
583/// # Ok::<(), ravenrs_movie::MovieError>(())
584/// ```
585#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
586pub struct MoviePlayer {
587    movie: Movie,
588    width: f32,
589    max_film_height: Option<f32>,
590    labels: MovieLabels,
591    cue: Option<MovieCue>,
592    narration: Option<Narration>,
593    logo: Option<LottieAnimation>,
594    next: Option<NextFilm>,
595    on_finished: Option<Rc<dyn Fn()>>,
596}
597
598impl std::fmt::Debug for MoviePlayer {
599    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
600        f.debug_struct("MoviePlayer").field("width", &self.width).finish_non_exhaustive()
601    }
602}
603
604#[cfg_attr(feature = "live", ravenrs_live::expose)]
605impl MoviePlayer {
606    /// A player for `movie`, stopped at its first picture.
607    pub fn new(movie: Movie) -> Self {
608        Self {
609            movie,
610            width: 960.0,
611            max_film_height: None,
612            labels: MovieLabels::default(),
613            cue: None,
614            narration: None,
615            logo: None,
616            next: None,
617            on_finished: None,
618        }
619    }
620
621    /// What comes after this film, offered when it ends.
622    #[must_use]
623    pub fn next(mut self, title: impl Into<String>, open: impl Fn() + 'static) -> Self {
624        self.next = Some((title.into(), Rc::new(open)));
625        self
626    }
627
628    /// Runs when the film reaches its end.
629    #[must_use]
630    pub fn on_finished(mut self, finished: impl Fn() + 'static) -> Self {
631        self.on_finished = Some(Rc::new(finished));
632        self
633    }
634
635    /// The film is no taller than this, and the chapters take the width it leaves.
636    /// For a player that should fit a window with its controls in view.
637    pub fn max_film_height(mut self, height: f32) -> Self {
638        self.max_film_height = Some(height);
639        self
640    }
641
642    /// The width of the whole player, in logical pixels (960 by default). The
643    /// film gets what its chapters leave.
644    pub fn width(mut self, width: f32) -> Self {
645        self.width = width;
646        self
647    }
648
649    /// Recordings of what the film says, in place of the system's voice. What is not
650    /// among them is still said by the system's voice.
651    #[must_use]
652    pub fn narration(mut self, narration: Narration) -> Self {
653        self.narration = Some(narration);
654        self
655    }
656
657    /// The app's animated logo, played on the film's opening and farewell cards.
658    #[must_use]
659    pub fn logo(mut self, logo: LottieAnimation) -> Self {
660        self.logo = Some(logo);
661        self
662    }
663
664    /// What an opening or a farewell card says at `at`, once its words are coming
665    /// in: told apart from the boxes by a number of its own. Only a recording says
666    /// it, since the system's voice would be a second voice in the film.
667    fn card_said(&self, at: u64) -> Option<(usize, String)> {
668        let project = self.movie.project();
669        let Some(Place::Title(index)) = project.place_at(at) else {
670            return None;
671        };
672        let Some(ClipKind::Title { title, subtitle, look, .. }) = project.clips.get(index).map(|c| &c.kind)
673        else {
674            return None;
675        };
676        let inside = at.saturating_sub(project.clip_start_ms(index));
677        (*look != CardLook::Plain && inside >= look.speaks_from_ms())
678            .then(|| (usize::MAX - index, format!("{title}. {subtitle}")))
679    }
680
681    /// The words it shows.
682    pub fn labels(mut self, labels: MovieLabels) -> Self {
683        self.labels = labels;
684        self
685    }
686
687    /// Lets something outside the player open a moment of the film.
688    pub fn cue(mut self, cue: MovieCue) -> Self {
689        self.cue = Some(cue);
690        self
691    }
692
693    fn chapters(
694        &self,
695        playhead: &Rc<Playhead>,
696        changes: &State<u64>,
697        list_height: Option<f32>,
698    ) -> Option<impl View + use<>> {
699        let movie = &self.movie;
700        if movie.chapters().is_empty() {
701            return None;
702        }
703        let current = movie.chapter_at(playhead.position_ms.get());
704        let rows: Vec<ListItem> = movie
705            .chapters()
706            .iter()
707            .enumerate()
708            .map(|(i, chapter)| {
709                let (playhead, changes, at) = (playhead.clone(), changes.clone(), chapter.at_ms);
710                let next = movie.chapters().get(i + 1).map_or(movie.duration_ms(), |next| next.at_ms);
711                ListItem::new(chapter.title.clone())
712                    .icon(if current.is_some_and(|now| i < now) { IconName::Check } else { IconName::Play })
713                    // What the current one teaches; for the others how long they take,
714                    // since where they start would read as that.
715                    .subtitle(if current == Some(i) {
716                        chapter.text.clone()
717                    } else {
718                        clock_label(next.saturating_sub(chapter.at_ms))
719                    })
720                    .selected(current == Some(i))
721                    .on_click(move || {
722                        playhead.seek(at);
723                        changes.update(|n| *n += 1);
724                    })
725            })
726            .collect();
727        let list = ScrollView::new((List::new(rows),));
728        // Beside the film, it scrolls rather than going past it.
729        let list = match list_height {
730            Some(height) => list.height(height),
731            None => list,
732        };
733        Some(
734            VStack::new((
735                Text::new(self.labels.chapters.clone())
736                    .style(TextStyle::Callout)
737                    .font_weight(Weight::SEMIBOLD),
738                list,
739            ))
740            .spacing(8.0)
741            .align(Align::Stretch),
742        )
743    }
744
745    /// What the film is saying right now: the waiting step, an opened or running
746    /// note, or what the chapter teaches.
747    fn caption(
748        &self,
749        playhead: &Rc<Playhead>,
750        changes: &State<u64>,
751        explaining: Option<&Hotspot>,
752        boxes: bool,
753    ) -> Option<impl View + use<>> {
754        // The box over the film already says it.
755        if boxes {
756            return None;
757        }
758        let movie = self.movie.clone();
759        let at = playhead.position_ms.get();
760        let waiting = playhead.waiting.get().and_then(|i| movie.hotspots().get(i)).filter(|_| !boxes);
761        let said = explaining
762            .filter(|_| !boxes)
763            .map(|spot| (spot.title.clone(), spot.text.clone()))
764            .or_else(|| {
765                movie
766                    .chapter_at(at)
767                    .and_then(|i| movie.chapters().get(i))
768                    .map(|c| (c.title.clone(), c.text.clone()))
769            })?;
770        let go_on = waiting.map(|_| {
771            let (playhead, changes, player) = (playhead.clone(), changes.clone(), movie.clone());
772            Button::new(self.labels.next_step.clone())
773                .style(ButtonStyle::Secondary)
774                .size(ButtonSize::Small)
775                .icon(IconName::ArrowRight)
776                .on_click(move || {
777                    playhead.go_on(&player);
778                    changes.update(|n| *n += 1);
779                })
780        });
781        Some(Card::new(
782            HStack::new((
783                Icon::new(if waiting.is_some() { IconName::MousePointer } else { IconName::Info })
784                    .color(Semantic::Accent),
785                VStack::new((
786                    Text::new(said.0).font_weight(Weight::SEMIBOLD),
787                    Text::new(said.1).style(TextStyle::Callout).foreground(Semantic::InkMuted),
788                ))
789                .spacing(2.0)
790                .grow(1.0),
791                go_on,
792            ))
793            .spacing(12.0)
794            .align(Align::Center),
795        ))
796    }
797
798    fn controls(
799        &self,
800        playhead: &Rc<Playhead>,
801        changes: &State<u64>,
802        guided: &State<bool>,
803        audio: (&State<bool>, &State<bool>),
804        film_width: f32,
805    ) -> impl View + use<> {
806        let duration = self.movie.duration_ms();
807        let running = playhead.is_running();
808        let (toggle_head, toggle_changes, toggle_movie) =
809            (playhead.clone(), changes.clone(), self.movie.clone());
810        let (back_head, back_changes) = (playhead.clone(), changes.clone());
811        let (speed_head, speed_changes) = (playhead.clone(), changes.clone());
812        let (read, write, moved) = (playhead.clone(), playhead.clone(), changes.clone());
813        let position = Binding::new(
814            move || read.position_ms.get() as f32,
815            move |ms: f32| {
816                write.seek(ms.max(0.0) as u64);
817                moved.update(|n| *n += 1);
818            },
819        );
820        let playing = HStack::new((
821            Button::new(if running { self.labels.pause.clone() } else { self.labels.play.clone() })
822                .icon(if running { IconName::Pause } else { IconName::Play })
823                .size(ButtonSize::Small)
824                .style(if running { ButtonStyle::Secondary } else { ButtonStyle::Primary })
825                .on_click(move || {
826                    if toggle_head.is_running() {
827                        toggle_head.pause();
828                    } else {
829                        toggle_head.play(&toggle_movie);
830                    }
831                    toggle_changes.update(|n| *n += 1);
832                }),
833            Button::new(self.labels.restart.clone())
834                .icon(IconName::RotateCcw)
835                .style(ButtonStyle::Ghost)
836                .size(ButtonSize::Small)
837                .on_click(move || {
838                    back_head.seek(0);
839                    back_changes.update(|n| *n += 1);
840                }),
841            Slider::new(position)
842                .range(0.0, duration.max(1) as f32)
843                .step(100.0)
844                .label(self.labels.position.clone())
845                .marks(self.movie.chapters().iter().skip(1).map(|chapter| chapter.at_ms as f32))
846                .grow(1.0),
847            Button::new(SPEED_NAMES[playhead.speed.get().min(SPEEDS.len() - 1)])
848                .style(ButtonStyle::Ghost)
849                .size(ButtonSize::Small)
850                .on_click(move || {
851                    speed_head.next_speed();
852                    speed_changes.update(|n| *n += 1);
853                }),
854            Text::new(format!("{} / {}", clock_label(playhead.position_ms.get()), clock_label(duration)))
855                .style(TextStyle::Caption)
856                .foreground(Semantic::InkMuted),
857        ))
858        .spacing(8.0)
859        .align(Align::Center);
860        let options = HStack::new((
861            Toggle::new(guided.binding()).label(self.labels.guided.clone()),
862            Toggle::new(audio.0.binding()).label(self.labels.sound.clone()),
863            Toggle::new(audio.1.binding()).label(self.labels.narration.clone()),
864        ))
865        .spacing(16.0)
866        .align(Align::Center)
867        .wrap();
868        VStack::new((playing, options)).spacing(8.0).align(Align::Stretch).width(film_width)
869    }
870
871    /// Plays the clicks and keys the film just went past, and has the voice start
872    /// on what the film says now, leaving what it said behind when the film
873    /// jumped, paused or lost its voice.
874    fn make_sound(
875        &self,
876        sound: &Sound,
877        clips: &FilmSounds,
878        playhead: &Rc<Playhead>,
879        before: u64,
880        explaining: Option<(usize, &Hotspot)>,
881        audio: (bool, bool),
882    ) {
883        let at = playhead.position_ms.get();
884        if audio.0 && (playhead.is_running() || playhead.waiting.get().is_some()) {
885            let recent = at.saturating_sub(SOUNDS_AT_MOST_MS).max(before);
886            for (_, kind) in self.movie.sounds_between(recent, at) {
887                sound.play(match kind {
888                    FilmSound::Click => &clips.click,
889                    FilmSound::Key => &clips.key,
890                });
891            }
892        }
893        let speaking = audio.1 && (playhead.is_running() || playhead.waiting.get().is_some());
894        if (playhead.jumped.take() || !speaking) && playhead.spoken.take().is_some() {
895            sound.stop_speaking();
896        }
897        if let Some((index, spot)) = explaining.filter(|_| speaking)
898            && playhead.spoken.get() != Some(index)
899        {
900            playhead.spoken.set(Some(index));
901            let said = format!("{}. {}", spot.title, spot.text);
902            match self.narration.as_ref().and_then(|narration| narration.clip(&said)) {
903                Some(clip) => sound.speak_clip(&clip),
904                None => sound.speak(&said, &self.labels.language),
905            }
906        } else if let Some((index, said)) = self.card_said(at).filter(|_| speaking && explaining.is_none())
907            && playhead.spoken.get() != Some(index)
908        {
909            playhead.spoken.set(Some(index));
910            if let Some(clip) = self.narration.as_ref().and_then(|narration| narration.clip(&said)) {
911                sound.speak_clip(&clip);
912            }
913        }
914    }
915}
916
917impl View for MoviePlayer {
918    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
919        let changes: State<u64> = cx.use_state(|| 0);
920        let guided: State<bool> = cx.use_state(|| true);
921        let playhead: State<Rc<Playhead>> = cx.use_state(|| Rc::new(Playhead::default()));
922        // Read, so that a click rebuilds this view.
923        let _ = changes.get();
924        let sound_on: State<bool> = cx.use_state(|| true);
925        let narration_on: State<bool> = cx.use_state(|| true);
926        let sound = cx.sound();
927        let clips =
928            cx.use_state(|| Rc::new(FilmSounds { click: SoundClip::click(), key: SoundClip::key() })).get();
929        let narrator = sound.clone();
930        let _narrator = cx.use_state(move || Rc::new(Narrator(narrator)));
931        let playhead = playhead.get();
932        let movie = &self.movie;
933        let now = cx.now();
934        if let Some(cue) = &self.cue {
935            // Read, so that asking for a moment rebuilds this view.
936            let _ = cue.changes.get();
937            if let Some(ms) = cue.request.take() {
938                playhead.open(movie, ms, guided.get());
939            }
940        }
941        let before = playhead.position_ms.get();
942        playhead.advance(movie, guided.get(), now);
943        let told = cx.use_state(|| Rc::new(Cell::new(false))).get();
944        let ended = movie.duration_ms() > 0 && playhead.position_ms.get() >= movie.duration_ms();
945        if ended && !told.replace(true) {
946            if let Some(finished) = &self.on_finished {
947                finished();
948            }
949        } else if !ended {
950            told.set(false);
951        }
952        for (key, forward) in [(Key::ArrowLeft, false), (Key::ArrowRight, true)] {
953            let (head, moved, length) = (playhead.clone(), changes.clone(), movie.duration_ms());
954            cx.shortcut(
955                key,
956                false,
957                Rc::new(move || {
958                    let at = head.position_ms.get();
959                    let to = if forward {
960                        (at + ARROW_JUMP_MS).min(length)
961                    } else {
962                        at.saturating_sub(ARROW_JUMP_MS)
963                    };
964                    head.seek(to);
965                    moved.update(|n| *n += 1);
966                }),
967            );
968        }
969
970        if playhead.is_running() {
971            cx.wake_at(now + FRAME);
972        }
973
974        let side_by_side = self.width >= SIDE_BY_SIDE_FROM && !movie.chapters().is_empty();
975        let room = if side_by_side { self.width - CHAPTERS_WIDTH - COLUMN_GAP } else { self.width };
976        let logical = movie.logical_size();
977        // The film's frame takes a pixel on each side.
978        let film_width = self.max_film_height.map_or(room, |height| {
979            room.min((height * logical.width / logical.height.max(1.0)).max(SMALLEST_FILM_WIDTH))
980        }) - 2.0;
981        let chapters_width =
982            if side_by_side { self.width - film_width - 2.0 - COLUMN_GAP } else { self.width };
983        let film_height = height_of(film_width, logical);
984        let at = playhead.position_ms.get();
985        let waiting = playhead.waiting.get();
986        let marks: Vec<Hotspot> = movie
987            .hotspots()
988            .iter()
989            .enumerate()
990            .filter(|(i, spot)| match spot.kind {
991                HotspotKind::Stop if guided.get() => waiting == Some(*i),
992                _ => spot.from_ms <= at && at < spot.until_ms,
993            })
994            .map(|(_, spot)| spot.clone())
995            .collect();
996        // What the film is explaining now: the step it waits at, then the note
997        // someone clicked, then whatever is on, the most recent first.
998        let spots = movie.hotspots();
999        let explaining = waiting
1000            .or(playhead.opened.get())
1001            .or_else(|| {
1002                spots.iter().enumerate().rev().find_map(|(i, spot)| {
1003                    let shown = spot.from_ms <= at && at < spot.until_ms;
1004                    let stops_instead = spot.kind == HotspotKind::Stop && guided.get();
1005                    (shown && !stops_instead).then_some(i)
1006                })
1007            })
1008            .and_then(|i| spots.get(i).map(|spot| (i, spot)));
1009        self.make_sound(&sound, &clips, &playhead, before, explaining, (sound_on.get(), narration_on.get()));
1010        let boxes = film_width >= BOX_FROM;
1011        let explained = explaining.map(|(_, spot)| spot.clone());
1012
1013        let stage = Stage {
1014            movie,
1015            playhead: playhead.clone(),
1016            width: film_width,
1017            marks,
1018            changes: changes.clone(),
1019            labels: &self.labels,
1020            explaining: explained.clone().filter(|_| boxes),
1021            next: self.next.as_ref(),
1022            logo: self.logo.clone(),
1023        };
1024        let stage = cx.child(&stage);
1025        // A hairline and a shadow, so a film that opens on white still has an edge.
1026        let theme = *cx.theme();
1027        let stage = cx.container(
1028            LayoutStyle { padding: 1.0.into(), ..LayoutStyle::stack(Axis::Vertical) },
1029            &[stage],
1030            Paint::Quad(QuadPaint {
1031                border: Some(Border { width: 1.0, color: theme.color(Semantic::Border) }),
1032                shadow: Some(theme.elevation.soft),
1033                ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
1034            }),
1035        );
1036        let audio = (&sound_on, &narration_on);
1037        let controls = cx.child(&self.controls(&playhead, &changes, &guided, audio, film_width));
1038        let mut column = vec![stage, controls];
1039        if let Some(caption) = self.caption(&playhead, &changes, explained.as_ref(), boxes) {
1040            let caption = cx.child(&caption.width(film_width));
1041            column.push(caption);
1042        }
1043        let column = cx.container(
1044            LayoutStyle { spacing: 12.0, ..LayoutStyle::stack(Axis::Vertical) },
1045            &column,
1046            Paint::None,
1047        );
1048        let list_height = side_by_side.then_some(film_height - CHAPTERS_TITLE);
1049        let chapters = self.chapters(&playhead, &changes, list_height).map(|chapters| {
1050            let view = chapters.width(chapters_width);
1051            cx.child(&view)
1052        });
1053        let children: Vec<NodeId> = [Some(column), chapters].into_iter().flatten().collect();
1054        cx.container(
1055            LayoutStyle {
1056                spacing: COLUMN_GAP,
1057                ..LayoutStyle::stack(if side_by_side { Axis::Horizontal } else { Axis::Vertical })
1058            },
1059            &children,
1060            Paint::None,
1061        )
1062    }
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::clock_label;
1068
1069    #[test]
1070    fn the_clock_reads_like_one() {
1071        assert_eq!(clock_label(0), "0:00");
1072        assert_eq!(clock_label(61_500), "1:01");
1073        assert_eq!(clock_label(600_000), "10:00");
1074    }
1075}