1use 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
22type NextFilm = (String, Rc<dyn Fn()>);
24
25const FRAME: f64 = 1.0 / 30.0;
28const SPEEDS: [f64; 3] = [1.0, 1.25, 1.5];
30const SPEED_NAMES: [&str; 3] = ["1×", "1,25×", "1,5×"];
31const ARROW_JUMP_MS: u64 = 5_000;
33const CHAPTERS_WIDTH: f32 = 260.0;
34const SMALLEST_FILM_WIDTH: f32 = 320.0;
36const CHAPTERS_TITLE: f32 = 28.0;
38const COLUMN_GAP: f32 = 16.0;
39const SIDE_BY_SIDE_FROM: f32 = 720.0;
41const 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;
48const BOX_FRAME_HEIGHT: f32 = 84.0;
50const BOX_LINE_HEIGHT: f32 = 20.0;
51const BOX_LETTERS_PER_LINE: usize = 42;
52const SOUNDS_AT_MOST_MS: u64 = 400;
55
56#[derive(Clone, Debug)]
58#[non_exhaustive]
59#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
60pub struct MovieLabels {
61 pub play: String,
63 pub pause: String,
65 pub restart: String,
67 pub guided: String,
69 pub next_step: String,
71 pub chapters: String,
73 pub position: String,
75 pub movie: String,
77 pub sound: String,
79 pub narration: String,
81 pub language: String,
83 pub step_of: String,
85 pub finished: String,
87 pub watch_again: String,
89 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 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 pub fn pt_br() -> Self {
124 Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
125 }
126}
127
128pub(crate) fn clock_label(ms: u64) -> String {
130 let seconds = ms / 1000;
131 format!("{}:{:02}", seconds / 60, seconds % 60)
132}
133
134#[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 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 pub fn go_to(&self, ms: u64) {
161 self.request.set(Some(ms));
162 self.changes.update(|n| *n += 1);
163 }
164
165 pub fn go_to_film(&self, film: usize, ms: u64) {
167 self.film.set(Some(film));
168 self.go_to(ms);
169 }
170
171 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#[derive(Debug, Default)]
187struct Playhead {
188 position_ms: Cell<u64>,
189 running: Cell<Option<(f64, u64)>>,
191 waiting: Cell<Option<usize>>,
193 opened: Cell<Option<usize>>,
195 spoken: Cell<Option<usize>>,
197 jumped: Cell<bool>,
199 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 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 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 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 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 fn advance(&self, movie: &Movie, guided: bool, now: f64) {
267 let Some((since, from)) = self.running.get() else {
268 return;
269 };
270 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
298struct Narrator(Sound);
300
301impl Drop for Narrator {
302 fn drop(&mut self) {
303 self.0.stop_speaking();
304 }
305}
306
307struct FilmSounds {
309 click: SoundClip,
310 key: SoundClip,
311}
312
313fn 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
339struct 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
380fn 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
395fn height_of(width: f32, logical: ravenrs_core::Size) -> f32 {
397 width * logical.height / logical.width.max(1.0)
398}
399
400struct 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 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 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 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 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#[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 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 #[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 #[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 pub fn max_film_height(mut self, height: f32) -> Self {
638 self.max_film_height = Some(height);
639 self
640 }
641
642 pub fn width(mut self, width: f32) -> Self {
645 self.width = width;
646 self
647 }
648
649 #[must_use]
652 pub fn narration(mut self, narration: Narration) -> Self {
653 self.narration = Some(narration);
654 self
655 }
656
657 #[must_use]
659 pub fn logo(mut self, logo: LottieAnimation) -> Self {
660 self.logo = Some(logo);
661 self
662 }
663
664 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 pub fn labels(mut self, labels: MovieLabels) -> Self {
683 self.labels = labels;
684 self
685 }
686
687 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 .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 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 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 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 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 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 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 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 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 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}