diff --git a/ROADMAP.md b/ROADMAP.md index 933bc7d1..6beeb4d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-18 | Slide show: a video or audio clip is looked up on the slide it belongs to, so slides whose shape ids repeat (the PPTX import numbers each slide afresh) no longer play each other's clips (#994) | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | | 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eef9b1b3..1de11554 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1347,8 +1347,9 @@ impl SlideApp { } UiRequest::Media { action, shape, ms } => { let owner = if self.show.is_some() { media::Owner::Show } else { media::Owner::Editor }; - if let Some((uid, doc)) = self.session.active().map(|d| (d.uid, d.doc.clone())) { - self.media.request(uid, &doc, &action, shape, ms, owner); + let shown = self.show.as_ref().map(|s| s.state.slide); + if let Some((uid, doc, slide)) = self.session.active().map(|d| (d.uid, d.doc.clone(), shown.unwrap_or(d.selection.slide))) { + self.media.request(uid, &doc, slide, &action, shape, ms, owner); } } } diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index 7fd8fda7..344689b2 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -318,9 +318,10 @@ impl MediaHost { } /// Carry out a `media.*` request from the engine for presentation `doc_uid`. - pub fn request(&mut self, doc_uid: u64, doc: &Presentation, action: &str, id: ShapeId, ms: Option, owner: Owner) { + /// `slide` is the slide the request comes from: shape ids are only unique per slide. + pub fn request(&mut self, doc_uid: u64, doc: &Presentation, slide: usize, action: &str, id: ShapeId, ms: Option, owner: Owner) { self.ensure_doc(doc_uid); - let Some(clip) = find_clip(doc, id) else { return }; + let Some(clip) = find_clip(doc, id, Some(slide)) else { return }; let t = ms.map(|v| v as f64 / 1000.0); match action { "play" => self.play(doc_uid, doc, id, &clip, t, owner), @@ -333,8 +334,9 @@ impl MediaHost { } } -/// The clip of media shape `id` anywhere in the deck. -pub fn find_clip(doc: &Presentation, id: ShapeId) -> Option { +/// The clip of media shape `id`. Ids are only unique per slide (the PPTX import numbers each slide +/// afresh), so the `near` slide is searched first, then the rest of the deck. +pub fn find_clip(doc: &Presentation, id: ShapeId, near: Option) -> Option { fn walk(shapes: &[deckcraft_model::Shape], id: ShapeId) -> Option { shapes.iter().find_map(|s| match &s.kind { ShapeKind::Media(m) if s.id == id => Some(m.clone()), @@ -342,7 +344,7 @@ pub fn find_clip(doc: &Presentation, id: ShapeId) -> Option { _ => None, }) } - doc.slides.iter().find_map(|s| walk(&s.shapes, id)) + near.and_then(|i| doc.slides.get(i)).and_then(|s| walk(&s.shapes, id)).or_else(|| doc.slides.iter().find_map(|s| walk(&s.shapes, id))) } /// Media shapes directly on slide `index`: (id, box in slide points, clip). @@ -617,4 +619,18 @@ mod tests { host.set_document(None); assert!(host.gifs.is_empty()); } + + #[test] + fn a_clip_is_looked_up_on_the_slide_it_was_asked_from() { + // The PPTX import numbers shapes per slide, so two slides can hold different clips under one id. + let (mut doc, id, first) = deck(4000, wav_level(0.05, 0.5)); + let other = doc.add_media("two.wav", "audio/wav", wav_level(0.05, 0.25)).expect("media"); + let mut second = (*doc.slides[0]).clone(); + second.shapes.last_mut().expect("shape").kind = ShapeKind::Media(MediaClip { media: other, volume: 1.0, ..Default::default() }); + doc.slides.push(Arc::new(second)); + assert_eq!(find_clip(&doc, id, Some(0)).map(|c| c.media), Some(first.media)); + assert_eq!(find_clip(&doc, id, Some(1)).map(|c| c.media), Some(other), "the second slide's own clip"); + assert_eq!(find_clip(&doc, id, None).map(|c| c.media), Some(first.media), "without a hint: first match"); + assert_eq!(find_clip(&doc, id, Some(9)).map(|c| c.media), Some(first.media), "a slide that doesn't exist"); + } } diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 51f859af..599e366a 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -1070,7 +1070,7 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentat if show.media_slide == cur { return; } - show.media_slide = cur; + let previous = std::mem::replace(&mut show.media_slide, cur); let trans = cur.and_then(|i| doc.slides.get(i)).and_then(|s| s.transition.as_ref()); let sound = trans.and_then(|t| t.sound); let sound_loop = trans.is_some_and(|t| t.sound_loop); @@ -1081,7 +1081,7 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentat .media .ids() .into_iter() - .filter(|id| crate::media::find_clip(doc, *id).is_some_and(|c| c.play_across_slides)) + .filter(|id| crate::media::find_clip(doc, *id, previous).is_some_and(|c| c.play_across_slides)) // A transition sound plays to its end, as in PowerPoint, unless the new slide has its own. .chain((sound.is_none() && !stop_previous).then_some(TRANSITION_SOUND).filter(|id| app.media.is_playing(*id))) .collect(), @@ -1110,7 +1110,7 @@ fn show_media(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentat /// the clip is already playing, Pause toggles, Stop stops. fn run_media_cues(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Presentation) { for (id, effect) in std::mem::take(&mut show.media_cues) { - let Some(clip) = crate::media::find_clip(doc, id) else { continue }; + let Some(clip) = crate::media::find_clip(doc, id, Some(show.state.slide)) else { continue }; match effect.as_str() { "pause" => app.media.toggle(doc_uid, doc, id, &clip, crate::media::Owner::Show), "stop" => app.media.stop(id),