Merge PR #140 from alexanderalber: Slide show: media set to play in the click sequence starts on Next (#116)

# Conflicts:
#	crates/anim/src/tests.rs
#	crates/ui-egui/src/show.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:20:15 -04:00
4 changed files with 135 additions and 4 deletions
+17
View File
@@ -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::<Vec<_>>(), [(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));
}
+17 -1
View File
@@ -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<EffectTiming> {
let mut v: Vec<EffectTiming> = 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()
+99 -3
View File
@@ -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<PlacedLink>, Vec<PlacedShape>)>,
/// 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());
}
}
+2
View File
@@ -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 |