1use 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
18const ANSWERS: usize = 4;
20const SPACING: f32 = 12.0;
21const TABS_FROM: f32 = 860.0;
23const PICKER_WIDTH: f32 = 280.0;
24const AROUND_THE_FILM: f32 = 184.0;
26
27#[derive(Clone, Debug)]
29#[non_exhaustive]
30#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
31pub struct AssistantLabels {
32 pub placeholder: String,
34 pub nothing_found: String,
36 pub film: String,
38 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 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 pub fn pt_br() -> Self {
62 Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
63 }
64}
65
66#[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 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 pub fn width(mut self, width: f32) -> Self {
105 self.width = width;
106 self
107 }
108
109 pub fn height(mut self, height: f32) -> Self {
112 self.height = Some(height);
113 self
114 }
115
116 #[must_use]
118 pub fn narration(mut self, narration: Narration) -> Self {
119 self.narration = Some(narration);
120 self
121 }
122
123 #[must_use]
125 pub fn logo(mut self, logo: LottieAnimation) -> Self {
126 self.logo = Some(logo);
127 self
128 }
129
130 pub fn labels(mut self, labels: AssistantLabels) -> Self {
132 self.labels = labels;
133 self
134 }
135
136 #[must_use]
139 pub fn cue(mut self, cue: MovieCue) -> Self {
140 self.cue = Some(cue);
141 self
142 }
143}
144
145pub(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
153pub(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
171pub(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#[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 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 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 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}