Merge PR #679 from NikitkaRa: Show: Show Media Controls hides the playback bar

# Conflicts:
#	crates/engine/src/cmd/review.rs
#	crates/ui-egui/src/show.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:46:55 -04:00
5 changed files with 108 additions and 12 deletions
+3
View File
@@ -20,6 +20,7 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (View › New Window; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (six original starter decks; previous: minor, hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: owned PPTX corpus for more deck kinds, handout-master text; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Show Media Controls; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -178,6 +179,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-13 | PPTX corpus round-trip for tables, charts, animations, media, sections and masters/layouts (open → save → reopen, package parts and text). A handout master's header, footer, date and slide number are kept |
| 2026-10-13 | Playback stays with its document and its clip (#415): switching decks or closing the last one stops the previous sound, replacing a clip plays the new one, and the duration and decode caches keep the bytes they are keyed by |
| 2026-10-13 | Slide show: a laser pointer (Cmd/Ctrl+L, the show's laser colour, no ink). Checklist row Partial; weighted breadth stays 79%, P2 summary 39%, slide show area 68%. The presenter window does not move the dot |
| 2026-10-14 | Slide Show: Show Media Controls writes the show setting and hides the playback bar and GIF button during the show |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
@@ -225,6 +227,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-13 | trivial | Progress log: owned PPTX corpus for more deck kinds; handout-master text |
| 2026-10-13 | trivial | Progress log: media playback follows the open document and clip (#415) |
| 2026-10-13 | trivial | Progress log: laser pointer. Headline P2 36% → 39% to match the checklist (the row moves P2 unweighted 38% → 39%; breadth stays 79%) |
| 2026-10-14 | trivial | Progress log: Show Media Controls |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
+18 -1
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Up Slide Show…",
["Slide Show", "Set Up"],
None,
"{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, from?, to?}",
"{type?: speaker|browsed|kiosk, loop?: bool, noNarration?, noAnimation?, useTimings?, showMediaControls?, from?, to?}",
has_doc,
setup
),
@@ -312,6 +312,9 @@ fn setup(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(v) = bool_param(p, "useTimings") {
sh.use_timings = v;
}
if let Some(v) = bool_param(p, "showMediaControls") {
sh.show_media_controls = v;
}
match (p.get("from").and_then(Value::as_u64), p.get("to").and_then(Value::as_u64)) {
(Some(a), Some(b)) => sh.range = Some((a.min(u32::MAX as u64) as u32, b.min(u32::MAX as u64) as u32)),
(None, None) if p.get("all").is_some() => sh.range = None,
@@ -398,4 +401,18 @@ mod tests {
// On wasm this used to return an empty string, so browser comments were saved undated.
assert!(now().starts_with("20"), "{}", now());
}
#[test]
fn show_media_controls_follow_setup() {
let mut s = Session::with_new();
assert!(s.doc().expect("doc").doc.show.show_media_controls);
let off = s.execute("show.setup", &json!({"showMediaControls": false})).expect("off");
assert_eq!(off["showMediaControls"], false);
assert_eq!(off["useTimings"], true, "other setup flags stay");
let timings = s.execute("show.setup", &json!({"useTimings": false})).expect("timings");
assert_eq!(timings["showMediaControls"], false);
assert_eq!(timings["useTimings"], false);
s.execute("show.setup", &json!({"showMediaControls": true})).expect("on");
assert!(s.doc().expect("doc").doc.show.show_media_controls);
}
}
+3 -1
View File
@@ -1865,7 +1865,9 @@ fn slide_show(app: &mut SlideApp, ui: &mut Ui) {
run(app, "show.setup", json!({"noNarration": !narr}));
}
let mut mc = app.session.active().is_none_or(|d| d.doc.show.show_media_controls);
let _ = ui.checkbox(&mut mc, "Show Media Controls");
if ui.checkbox(&mut mc, "Show Media Controls").changed() {
run(app, "show.setup", json!({"showMediaControls": mc}));
}
});
});
}
+80 -10
View File
@@ -474,8 +474,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
let mut actions: Vec<ShowAction> = vec![];
// The clock of the triggered animations runs before anything reacts to the pointer.
show.tick_triggers(&doc, now);
let on_slide = !show.pen && !show.laser && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx);
let gif_click = if clicked && on_slide { gif_hit(&ctx, ui, &show, doc_uid, &doc, app) } else { None };
let on_slide = !show.pen && !show.laser && !show.ended && show.blank.is_none() && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show);
let gif_click = if clicked && on_slide && doc.show.show_media_controls { gif_hit(&ctx, ui, &show, doc_uid, &doc, app) } else { None };
let media_click = if clicked && on_slide && gif_click.is_none() { media_hit(&ctx, ui, &show, &doc) } else { None };
let under = if on_slide && gif_click.is_none() && media_click.is_none() { shapes_under_pointer(&ctx, ui, &mut show, &doc, now) } else { None };
let pointing = under.as_ref().is_some_and(|u| u.pick.is_some());
@@ -491,7 +491,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
LinkJump::Exit => keep = false,
LinkJump::None => {}
}
} else if clicked && !show.pen && !show.reading_bar_hit(&ctx) {
} else if clicked && !show.pen && !show.reading_bar_hit(&ctx) && !pointer_on_media_bar(&ctx, ui, &doc, &show) {
if show.ended {
keep = false;
} else if show.blank.is_some() {
@@ -725,13 +725,29 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
};
crate::media::paint_frame(&painter, &app.media, *id, r);
}
// Play/pause on the animated GIF under the pointer.
if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && now - show.last_move < 2.5) {
for g in &gifs {
let r = on_screen(srect, k, &g.rect);
if r.contains(p) {
let b = crate::media::gif_button_rect(r);
crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p));
// Play/pause on the animated GIF under the pointer, and the audio/video bar.
// Slide Show ▸ Show Media Controls hides both (the click still plays a clip).
if doc.show.show_media_controls {
if let Some(p) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| !show.pen && !show.laser && now - show.last_move < 2.5) {
for g in &gifs {
let r = on_screen(srect, k, &g.rect);
if r.contains(p) {
let b = crate::media::gif_button_rect(r);
crate::media::paint_gif_button(&painter, b, g.paused, b.contains(p));
}
}
}
if let Some((id, clip, bar)) = media_bar_target(&ctx, &doc, &show, srect) {
let action = {
let status = app.media.status(id);
crate::media::control_bar(ui, bar, status.as_ref(), &clip)
};
let pos = app.media.status(id).map(|s| s.position).unwrap_or(0.0);
match action {
Some(crate::media::BarAction::Toggle) => app.media.toggle(doc_uid, &doc, id, &clip, crate::media::Owner::Show),
Some(crate::media::BarAction::Seek(t)) => app.media.seek(doc_uid, &doc, id, &clip, t, crate::media::Owner::Show),
Some(crate::media::BarAction::Nudge(dt)) => app.media.seek(doc_uid, &doc, id, &clip, (pos + dt).max(0.0), crate::media::Owner::Show),
None => {}
}
}
}
@@ -936,6 +952,60 @@ fn run_media_cues(app: &mut SlideApp, show: &mut Show, doc_uid: u64, doc: &Prese
}
}
/// Slide rectangle the show draws into (the reading bar keeps its strip).
fn shown_slide_rect(ui: &Ui, show: &Show, doc: &Presentation) -> Rect {
let full = ui.max_rect();
let avail = if show.reading { Rect::from_min_max(full.min, pos2(full.max.x, full.max.y - 34.0)) } else { full };
fit(avail, doc)
}
/// Playback bar for one clip: along the bottom inside a large shape, just under a small icon.
fn media_bar_rect(shape: Rect) -> Rect {
let h = 26.0f32;
let w = shape.width().clamp(160.0, 420.0);
if shape.height() >= h + 16.0 && shape.width() >= 160.0 {
Rect::from_min_size(pos2(shape.min.x + 4.0, shape.max.y - h - 4.0), vec2((shape.width() - 8.0).min(w), h))
} else {
Rect::from_min_size(pos2(shape.center().x - w / 2.0, shape.max.y + 4.0), vec2(w, h))
}
}
fn slide_media_bars(srect: Rect, doc: &Presentation, slide: usize) -> Vec<(ShapeId, deckcraft_model::MediaClip, Rect, Rect)> {
let k = srect.width() / doc.slide_size.width.max(1.0) as f32;
crate::media::slide_media(doc, slide)
.into_iter()
.map(|(id, x, clip)| {
let r = Rect::from_min_size(pos2(srect.min.x + x.x as f32 * k, srect.min.y + x.y as f32 * k), vec2(x.w as f32 * k, x.h as f32 * k));
let bar = media_bar_rect(r);
(id, clip, r, bar)
})
.collect()
}
/// The clip whose bar or shape is under the pointer, when Show Media Controls is on.
fn media_bar_target(ctx: &egui::Context, doc: &Presentation, show: &Show, srect: Rect) -> Option<(ShapeId, deckcraft_model::MediaClip, Rect)> {
if !doc.show.show_media_controls || show.pen || show.ended || show.blank.is_some() {
return None;
}
let p = ctx.input(|i| i.pointer.hover_pos())?;
let items = slide_media_bars(srect, doc, show.state.slide);
if let Some((id, clip, _, bar)) = items.iter().rev().find(|(_, _, _, bar)| bar.contains(p)) {
return Some((*id, clip.clone(), *bar));
}
let (id, clip, _, bar) = items.iter().rev().find(|(_, _, r, _)| r.contains(p))?;
Some((*id, clip.clone(), *bar))
}
/// True when the pointer is on a visible playback bar, so that click must not advance the show.
fn pointer_on_media_bar(ctx: &egui::Context, ui: &Ui, doc: &Presentation, show: &Show) -> bool {
if !doc.show.show_media_controls || show.pen || show.ended || show.blank.is_some() {
return false;
}
let Some(p) = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.hover_pos())) else { return false };
let srect = shown_slide_rect(ui, show, doc);
slide_media_bars(srect, doc, show.state.slide).iter().any(|(_, _, _, bar)| bar.contains(p))
}
/// 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())?;
+4
View File
@@ -4,6 +4,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (video seek frame-loss race fixed, upstream #416; previous: major, created; measured from `crates/ui-egui/src/show.rs` and `crates/anim/src/show.rs` against PowerPoint 16.113's Slide Show tab and menu) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (keyboard navigation from Presenter View) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (laser pointer in the show; the presenter window still does not move it; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Show Media Controls hides the show's playback bar and GIF button; breadth stays 66%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view,
rehearsal and recording. Transition and animation playback are in
@@ -30,6 +31,8 @@ ready** (estimated), **10–16 h** to parity.
| 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) | |
| Media playback with controls, play across slides, full-screen video | yes | yes | WMA/WMV not decodable. Slide Show ▸ Show Media Controls (on by default) hides the playback bar |
| Animated GIFs play; pause from their corner button | yes | yes (#39) | The corner button hides with Show Media Controls |
| See All Slides grid during the show | yes | no | |
| Zoom into part of a slide during the show | yes | no | |
| Pen and highlighter ink | yes | pen only (Cmd+P pen, Cmd+A arrow, E erases all ink) | No highlighter, per-stroke eraser, ink colours, or keep/discard annotations prompt |
@@ -87,4 +90,5 @@ Reading View in a window with navigation: present (checklist row done).
| 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-13 | minor | The show has a laser pointer (Cmd/Ctrl+L, the show's laser colour, no ink). Presenter view still does not drive it |
| 2026-10-14 | trivial | Show Media Controls hides the show's playback bar and GIF button |
| 2026-10-10 | major | Created: show, presenter view, rehearse and record, compared with PowerPoint 16.113's Slide Show tab and menu |