mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user