From 208c16364782cd83a19f08f095d0138ed1065422 Mon Sep 17 00:00:00 2001 From: Alexander Alber <90604107+alexanderalber@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:55:36 +0200 Subject: [PATCH] Slide show: media set to play in the click sequence starts on Next (#116) PPTX import already read PowerPoint's media effects (presetClass mediacall: Play, Pause, Stop) into the timeline, but the show never carried them out: media started only with autoplay or a click on it. The show now sets off a step's media effects as the step reaches them, and also when a click cuts the step short. A step also no longer lasts as long as the media it starts: the video plays on by itself and the next click goes on at once. An After Previous effect behind it still waits for the media to end. generated with Claude Code --- crates/anim/src/tests.rs | 17 ++++++ crates/anim/src/timeline.rs | 18 ++++++- crates/ui-egui/src/show.rs | 102 ++++++++++++++++++++++++++++++++++-- docs/slideshow-parity.md | 3 +- 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 1fd82d78..48544b7c 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -789,3 +789,20 @@ fn show_follows_links() { assert_eq!(s.follow(&p, &Action::LastViewed, None), LinkJump::None); assert_eq!(s.slide, 3); } + +#[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 e73acfd9..cc477904 100644 --- a/crates/anim/src/timeline.rs +++ b/crates/anim/src/timeline.rs @@ -249,7 +249,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 { @@ -308,6 +310,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 1c8e6e1e..826b30c2 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -42,6 +42,10 @@ pub struct Show { last_viewed: Option, /// Click areas of hyperlinks and shape actions on slide `.0`, in slide points. links: Option<(usize, 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)] @@ -95,6 +99,8 @@ impl Show { media_slide: None, last_viewed: None, links: None, + media_clock: None, + media_cues: vec![], } } @@ -114,10 +120,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) => { @@ -145,6 +158,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; @@ -168,11 +182,28 @@ impl Show { let auto = self.timeline(doc).step_is_auto(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) -> ShapeState { @@ -352,7 +383,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); @@ -670,6 +706,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())?; @@ -843,4 +894,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 fd981595..3eae40ea 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -20,7 +20,7 @@ ready** (estimated), **10–16 h** to parity. | Step clock: a click after a long pause plays its step (#11) | yes | yes | | | Hidden slides skipped | yes | yes | | | Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`) | -| 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, 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 | | @@ -73,4 +73,5 @@ Reading View in a window with navigation: present (checklist row done). | Date | Change | Summary | |---|---|---| +| 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 |