diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index eddff24f..3e9208cb 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -893,3 +893,20 @@ fn same_text_morphs_even_by_objects() { b.shapes = vec![titled(2, 40.0, 44.0, "Other")]; assert!(morph_frame(&p, &a, &b, 0.5, &|_| false).text.is_empty()); } + +#[test] +fn media_in_the_click_sequence_cues_and_does_not_hold_the_click() { + // #116: a video set to play in the click sequence starts with its step, and the step doesn't + // last as long as the video (the next click goes on while it plays). + let play = Animation { duration_ms: 5000, ..anim(1, AnimClass::Media, "play", AnimStart::OnClick) }; + let tl = Timeline::new(&slide_with(vec![play.clone(), anim(2, AnimClass::Exit, "fadeOut", AnimStart::OnClick)]), W, H); + assert_eq!(tl.steps(), 2); + assert_eq!(tl.step_duration(0), 0.0); + let cues = tl.media_cues(0, f64::NEG_INFINITY, 0.0); + assert_eq!(cues.iter().map(|e| (e.shape, e.anim)).collect::>(), [(ShapeId(1), 0)]); + assert!(tl.media_cues(0, 0.0, f64::INFINITY).is_empty(), "set off once"); + assert!(tl.media_cues(1, f64::NEG_INFINITY, f64::INFINITY).is_empty(), "only media effects"); + // An After Previous effect still waits for the video. + let tl = Timeline::new(&slide_with(vec![play, anim(2, AnimClass::Entrance, "fade", AnimStart::AfterPrevious)]), W, H); + assert!((tl.step_duration(0) - 6.0).abs() < 1e-9, "{}", tl.step_duration(0)); +} diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs index f973039c..98e91f96 100644 --- a/crates/anim/src/timeline.rs +++ b/crates/anim/src/timeline.rs @@ -267,7 +267,9 @@ impl Timeline { max_end = max_end.max(end); let step = steps.len().saturating_sub(1); if let Some(s) = steps.last_mut() { - s.duration = s.duration.max(end); + // Media plays on by itself once started: it holds back an After Previous + // effect (`max_end`), but not the next click (#116). + s.duration = s.duration.max(if a.class == AnimClass::Media { start } else { end }); } tl.effects.push(Effect { timing: EffectTiming { @@ -326,6 +328,20 @@ impl Timeline { self.main.get(i).map(|s| s.duration).unwrap_or(0.0) } + /// Media effects (Play, Pause, Stop) of main step `step` that start after `from` and by `to` + /// seconds into it, in order: what a show running the step from `from` to `to` sets off. + pub fn media_cues(&self, step: usize, from: f64, to: f64) -> Vec { + let mut v: Vec = self + .effects + .iter() + .map(|e| &e.timing) + .filter(|e| e.class == AnimClass::Media && e.sequence == Sequence::Main && e.step == step && e.start > from && e.start <= to) + .cloned() + .collect(); + v.sort_by(|a, b| a.start.total_cmp(&b.start)); + v + } + /// Total duration of the main sequence played without pauses. pub fn total_duration(&self) -> f64 { self.main.iter().map(|s| s.duration).sum() diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 56db26d9..98f5b0f7 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -43,6 +43,10 @@ pub struct Show { /// Click areas on slide `.0`, in slide points: hyperlinks and shape actions, and the slide's /// shapes in drawing order. links: Option<(usize, Vec, Vec)>, + /// The main step whose media effects are being set off, and how far into it they have been. + media_clock: Option<(usize, f64)>, + /// Media effects set off and not yet carried out: (shape, effect id). + media_cues: Vec<(ShapeId, String)>, } #[derive(Clone, PartialEq)] @@ -102,6 +106,8 @@ impl Show { media_slide: None, last_viewed: None, links: None, + media_clock: None, + media_cues: vec![], } } @@ -121,10 +127,17 @@ impl Show { fn apply(&mut self, a: ShowAction, doc: &Presentation, now: f64) -> bool { match a { - ShowAction::PlayStep(_) => { + ShowAction::PlayStep(k) => { self.step_start = now; + self.media_clock = Some((k, f64::NEG_INFINITY)); } - ShowAction::FinishStep(_) | ShowAction::StepBack(_) => { + ShowAction::FinishStep(n) => { + // A step cut short by a click still sets off the media it holds. + self.cue_media(doc, n, f64::INFINITY); + self.step_end = now; + } + ShowAction::StepBack(_) => { + self.media_clock = None; self.step_end = now; } ShowAction::Slide(i) => { @@ -153,6 +166,7 @@ impl Show { self.slide_start = now; self.step_start = now; self.step_end = now; + self.media_clock = None; self.strokes.clear(); if self.preview_only { return false; @@ -178,11 +192,28 @@ impl Show { let auto = self.timeline(doc).step_is_auto(0) && self.state.step == 0; if auto || self.autoplay { let a = self.state.next(doc); - if matches!(a, ShowAction::PlayStep(_)) { + if let ShowAction::PlayStep(k) = a { self.step_start = now; + self.media_clock = Some((k, f64::NEG_INFINITY)); } } } + + /// Queue the media effects (Play, Pause, Stop) of main step `step` that the show reached since + /// the last call, up to `t` seconds into the step (#116). + fn cue_media(&mut self, doc: &Presentation, step: usize, t: f64) { + let Some((k, from)) = self.media_clock else { return }; + if k != step { + self.media_clock = None; + return; + } + let slide = self.state.slide; + let cues = self.timeline(doc).media_cues(step, from, t); + let effects: Vec<(ShapeId, String)> = + cues.iter().filter_map(|e| doc.slides.get(slide).and_then(|s| s.animations.get(e.anim)).map(|a| (e.shape, a.effect.clone()))).collect(); + self.media_cues.extend(effects); + self.media_clock = if t.is_finite() { Some((k, t)) } else { None }; + } } fn state_for(tl: &Timeline, id: ShapeId, step: usize, t: f64, plays: &[TriggerPlay], stamps: &[StepStamp]) -> ShapeState { @@ -406,7 +437,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { keep = false; } } + if show.state.playing { + let step = show.state.step; + show.cue_media(&doc, step, now - show.step_start); + } show_media(app, &mut show, &doc); + run_media_cues(app, &mut show, &doc); // Draw. let full = ui.max_rect(); let painter = ui.painter_at(full); @@ -767,6 +803,21 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { } } +/// Carry out the media effects the show's steps set off: Play starts from the beginning unless +/// the clip is already playing, Pause toggles, Stop stops. +fn run_media_cues(app: &mut SlideApp, show: &mut Show, doc: &Presentation) { + for (id, effect) in std::mem::take(&mut show.media_cues) { + let Some(clip) = crate::media::find_clip(doc, id) else { continue }; + match effect.as_str() { + "pause" => app.media.toggle(doc, id, &clip, crate::media::Owner::Show), + "stop" => app.media.stop(id), + // Already playing (it also starts automatically): leave it running. + _ if app.media.is_playing(id) => {} + _ => app.media.play(doc, id, &clip, Some(0.0), crate::media::Owner::Show), + } + } +} + /// The media shape under the pointer on the shown slide. fn media_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc: &Presentation) -> Option<(ShapeId, deckcraft_model::MediaClip)> { let p = ctx.input(|i| i.pointer.interact_pos())?; @@ -1120,4 +1171,49 @@ mod tests { assert_eq!(tr.from, Some(0)); assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one"); } + + #[test] + fn media_in_the_click_sequence_plays_with_its_step() { + // #116: media set to play in the click sequence never started; only a click on it did. + let s = deckcraft_engine::Session::with_new(); + let mut doc = s.active().unwrap().doc.clone(); + let id = doc.slides[0].shapes[0].id; + let play = |delay_ms| deckcraft_model::Animation { + shape: id, + class: deckcraft_model::AnimClass::Media, + effect: "play".into(), + duration_ms: 5000, + delay_ms, + ..Default::default() + }; + std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut doc).slides[0]).animations = vec![play(0), play(2000)]; + let mut show = Show::new(&doc, 0, false, false); + assert!(show.apply(ShowAction::Slide(0), &doc, 0.0)); + // Next: the first step plays and sets off its Play once. + let a = show.state.next(&doc); + assert_eq!(a, ShowAction::PlayStep(0)); + assert!(show.apply(a, &doc, 1.0)); + show.cue_media(&doc, 0, 0.0); + show.cue_media(&doc, 0, 0.5); + assert_eq!(std::mem::take(&mut show.media_cues), [(id, "play".to_string())]); + // The step doesn't wait for the video: the step clock ends it at once. + assert_eq!(show.timeline(&doc).step_duration(0), 0.0); + let a = show.state.step_done(&doc); + assert!(show.apply(a, &doc, 1.0)); + assert!(show.media_cues.is_empty(), "set off once"); + // Next again, before the second Play's delay: cutting the step short still sets it off. + let a = show.state.next(&doc); + assert_eq!(a, ShowAction::PlayStep(1)); + assert!(show.apply(a, &doc, 2.0)); + show.cue_media(&doc, 1, 0.5); + assert!(show.media_cues.is_empty()); + let a = show.state.step_done(&doc); + assert!(show.apply(a, &doc, 2.6)); + assert_eq!(std::mem::take(&mut show.media_cues), [(id, "play".to_string())]); + // Going back doesn't set anything off. + let a = show.state.prev(&doc); + assert!(show.apply(a, &doc, 3.0)); + show.cue_media(&doc, 1, 9.0); + assert!(show.media_cues.is_empty()); + } } diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index fc2cc074..bd0bb50e 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -27,6 +27,7 @@ ready** (estimated), **10–16 h** to parity. | "Advance on click" off: a click never leaves the slide (animation steps still play, keys still move on) | yes | yes | | | Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable | | Media playback with controls, play across slides, full-screen video | yes | yes | Seeks and loops preserve new-generation video frames and end-of-stream; queue regression and repeated seeks across H.264/HEVC/VP9/AV1 in `crates/media`. WMA/WMV not decodable | +| Media playback with controls, play across slides, full-screen video, Play/Pause/Stop in the click sequence | yes | yes | WMA/WMV not decodable; click sequence since #116 | | Animated GIFs play; pause from their corner button | yes | yes (#39) | | | See All Slides grid during the show | yes | no | | | Zoom into part of a slide during the show | yes | no | | @@ -83,4 +84,5 @@ Reading View in a window with navigation: present (checklist row done). | 2026-10-13 | minor | Click handling for interactive decks: triggers, per-slide state kept across visits, visible-shape hit testing with hand cursor, advance on click | | 2026-10-13 | minor | Video seek handoff no longer discards freshly decoded frames or end-of-stream (upstream #416); deterministic queue and four-codec seek regression tests | | 2026-10-14 | minor | Presenter View handles slideshow keys in its own viewport, including returning from the end screen and Escape; regression tests cover navigation and avoiding duplicate button activation | +| 2026-10-11 | minor | Media effects (Play, Pause, Stop) in the click sequence play with their step (#116) | | 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu |