LRC-SS-PLAYBACK: a manual slideshow waits for the arrow keys

The slideshow always advanced on its own after the slide duration.
The new Manual slideshow setting (Settings > Slideshow > Playback, or
manual in slideshow.playback and view.slideshow) keeps each slide, the
intro and the ending up until the right or left arrow or the control
bar changes it. Space, Esc, random order and repeat work as before.
Exports still use the slide duration. The setting is saved with the
library and in slideshow templates; a template without it reads as off.
This commit is contained in:
dotCooCoo
2026-10-24 01:46:49 -07:00
parent 4ae3089420
commit adf94a87cb
8 changed files with 107 additions and 15 deletions
+1 -1
View File
@@ -379,7 +379,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Slideshow Playback Settings",
[],
None,
"{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}",
"{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due), manual?: bool (manual slideshow: a slide stays up until ← or → changes it)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews, manual}",
always,
playback
),
+51 -6
View File
@@ -1,6 +1,7 @@
//! Slideshow playback: the settings of Lightroom Classic's Playback panel and the timeline that
//! plays the photos in view (durations, fades, random order, repeat, pause, stepping), between an
//! optional intro and ending screen (the Titles panel, [`crate::slide_title`]).
//! plays the photos in view (durations, fades, random order, repeat, pause, stepping, manual
//! advance), between an optional intro and ending screen (the Titles panel,
//! [`crate::slide_title`]).
//!
//! No UI here: [`Playback`] is a pure state machine driven with injected times (seconds on any
//! monotonic clock), so the desktop app, a later Slideshow module and tests run the same timeline.
@@ -39,11 +40,22 @@ pub struct PlaybackSettings {
pub repeat: bool,
/// Render the next slides before they are due.
pub prepare_previews: bool,
/// Manual slideshow: a slide stays up until ← / → (or the controls) change it; the slide
/// duration is not used for playback then.
pub manual: bool,
}
impl Default for PlaybackSettings {
fn default() -> Self {
PlaybackSettings { slide_duration: 4.0, fade_duration: 1.0, fade_color: None, random_order: false, repeat: true, prepare_previews: true }
PlaybackSettings {
slide_duration: 4.0,
fade_duration: 1.0,
fade_color: None,
random_order: false,
repeat: true,
prepare_previews: true,
manual: false,
}
}
}
@@ -66,6 +78,7 @@ impl PlaybackSettings {
"randomOrder": self.random_order,
"repeat": self.repeat,
"preparePreviews": self.prepare_previews,
"manual": self.manual,
})
}
@@ -82,8 +95,11 @@ impl PlaybackSettings {
"randomOrder" => s.random_order = boolean(k, v)?,
"repeat" => s.repeat = boolean(k, v)?,
"preparePreviews" => s.prepare_previews = boolean(k, v)?,
"manual" => s.manual = boolean(k, v)?,
other => {
return Err(format!("unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews)"));
return Err(format!(
"unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews, manual)"
));
}
}
}
@@ -432,8 +448,12 @@ impl Playback {
self.started + self.duration(self.current())
}
/// Seconds until the next slide is due (as of `now`; frozen while paused).
/// Seconds until the next slide is due (as of `now`; frozen while paused; infinite in a
/// manual slideshow, where no slide is ever due).
pub fn remaining(&self, now: f64) -> f64 {
if self.settings.manual {
return f64::INFINITY;
}
(self.due() - self.clock(now)).max(0.0)
}
@@ -490,7 +510,8 @@ impl Playback {
self.started = now;
return Tick::Idle;
}
if now < self.due() {
// a manual slideshow moves only with `step` (← / →)
if self.settings.manual || now < self.due() {
return Tick::Idle;
}
let Some(next) = self.upcoming(1).first().copied() else {
@@ -777,6 +798,30 @@ mod tests {
assert!(Playback::new(vec![], None, d, 0, 0.0).is_none());
}
#[test]
fn a_manual_slideshow_moves_only_with_the_arrows() {
let s = PlaybackSettings { manual: true, repeat: false, ..settings(1.0, 0.5) };
let mut p = titled(3, s, Some(1.0), Some(1.0));
assert_eq!(p.tick(1000.0, all_ready), Tick::Idle, "the intro stays up past its duration");
assert_eq!(p.current(), Item::INTRO);
assert_eq!(p.remaining(1000.0), f64::INFINITY, "nothing is ever due");
assert_eq!(p.upcoming(1), vec![ph(1)], "the next slide is still prepared for →");
assert_eq!(p.step(1, 1000.0), Tick::Advanced(ph(1)));
assert_eq!(p.tick(5000.0, all_ready), Tick::Idle);
assert_eq!(p.current(), ph(1), "a photo stays up too");
assert_eq!(p.step(-1, 5000.0), Tick::Advanced(Item::INTRO), "← goes back");
assert_eq!(p.step(4, 5000.0), Tick::Advanced(Item::ENDING));
assert_eq!(p.tick(9000.0, all_ready), Tick::Idle, "the ending stays up until → ends the show");
assert_eq!(p.step(1, 9000.0), Tick::Ended);
// the setting round-trips and is checked like the others
let d = PlaybackSettings::default();
assert!(!d.manual, "off by default");
let m = d.merged(&json!({"manual": true})).unwrap();
assert!(m.manual);
assert_eq!(m.to_json()["manual"], true);
assert!(d.merged(&json!({"manual": "yes"})).is_err());
}
#[test]
fn a_single_photo_repeats_without_changing() {
let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap();
+2 -2
View File
@@ -403,7 +403,7 @@ fn slideshow_playback_settings_persist_with_the_library() {
let r = s
.execute(
"slideshow.playback",
&json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false}),
&json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "manual": true}),
)
.unwrap();
assert_eq!(r["fadeColor"], "#000000");
@@ -417,7 +417,7 @@ fn slideshow_playback_settings_persist_with_the_library() {
let r = s.execute("slideshow.playback", &json!({})).unwrap();
assert_eq!(
r,
json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true})
json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true, "manual": true})
);
drop(s);
// out-of-range values written by hand are clamped when the library opens
+5
View File
@@ -805,6 +805,11 @@ fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN};
heading(ui, t, crate::i18n::tr("Playback"));
let s = app.session.slideshow.clone();
let mut v = s.manual;
if check(ui, "settings.slideshowManual", &mut v, "Manual slideshow") {
let _ = app.run("slideshow.playback", json!({"manual": v}));
}
hint(ui, t, crate::i18n::tr("A manual slideshow shows each slide until → or ← changes it. Exports still use the slide duration."));
row(ui, t, crate::i18n::tr("Slide duration"), |ui| {
duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX)
});
+5 -3
View File
@@ -1,6 +1,6 @@
//! The slideshow (View ▸ Slideshow, ⌥⌘↩): the photos in view, full screen, played with the
//! library's Playback settings (`slideshow.playback`: slide and fade durations, fade colour,
//! random order, repeat, preparing previews in advance).
//! random order, repeat, preparing previews in advance, manual slideshow).
//!
//! The timeline is [`lightcraft_engine::slideshow::Playback`] (no egui, tested on its own); this
//! module renders the slides, draws the frame it asks for and the playback controls.
@@ -53,7 +53,7 @@ pub struct Run {
}
/// `view.slideshow {interval?, seed?, slideDuration?, fadeDuration?, fadeColor?, randomOrder?,
/// repeat?, preparePreviews?}`: play the photos in view from the selected one with the library's
/// repeat?, preparePreviews?, manual?}`: play the photos in view from the selected one with the library's
/// playback settings; the keys given change them for this run only (`interval` = slideDuration,
/// `seed` fixes the random order). The intro and ending screens that are on (`slideshow.titles`)
/// play before the first photo and after the last one. With no photos in view there is nothing
@@ -127,7 +127,9 @@ pub fn state(play: &Playback, now: f64) -> Value {
"fading": f.from.is_some(),
"progress": f.progress,
"waitingFor": play.waiting_for().map(|i| i.photo().map_or_else(|| json!(i.title().map(Title::id)), |p| json!(p.0))),
// (null in a manual slideshow: no slide is ever due)
"remaining": play.remaining(now),
"manual": play.settings().manual,
})
}
@@ -174,7 +176,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let f = run.play.frame(now);
if f.from.is_some() {
ctx.request_repaint(); // a fade draws every frame
} else if !run.play.is_paused() {
} else if !run.play.is_paused() && !run.play.settings().manual {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(run.play.remaining(now).clamp(0.016, 120.0)));
}
if run.controls_until > now {
+40
View File
@@ -150,6 +150,46 @@ fn arrows_cut_and_repeat_off_ends_after_the_last_slide() {
h.settle(SETTLE);
}
#[test]
fn a_manual_slideshow_waits_for_the_arrow_keys() {
let mut h = grid();
let photos = h.app.session.visible_cloned();
// the library setting, as the Settings checkbox sets it
run(&mut h, "slideshow.playback", json!({"manual": true}));
let r = run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0}));
assert_eq!(r["settings"]["manual"], true, "{r}");
// once the first two slides are rendered, and far longer than the slide duration: the first
// photo stays up
assert!(h.step_until(SETTLE, |h| has_slide(h, photos[0]) && has_slide(h, photos[1])), "the slides are rendered");
for _ in 0..120 {
h.step();
}
assert_eq!(photo_up(&h), photos[0], "nothing advances on its own");
let r = h.request("ui.key", json!({"key": "right"}), T);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(photo_up(&h), photos[1]);
assert_eq!(h.app.session.active(), Some(photos[1]), "the selection follows");
let s = run(&mut h, "slideshow.togglePause", json!({}));
assert_eq!((s["manual"].clone(), s["remaining"].clone()), (json!(true), json!(null)), "{s}");
run(&mut h, "slideshow.end", json!({}));
assert!(h.app.ui.slideshow.is_none());
// off again: it advances by itself
run(&mut h, "slideshow.playback", json!({"manual": false}));
run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0}));
let up = photo_up(&h);
assert!(h.step_until(SETTLE, |h| h.app.ui.slideshow.as_ref().is_some_and(|r| r.play.current_photo() != Some(up))));
run(&mut h, "slideshow.end", json!({}));
// the Settings checkbox turns it on
run(&mut h, "view.slideshowOptions", json!({}));
h.step();
reveal(&mut h, "check:settings.slideshowManual");
let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowManual"}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
assert!(h.app.session.slideshow.manual, "the checkbox goes through slideshow.playback");
h.settle(SETTLE);
}
#[test]
fn random_order_starts_at_the_selection_and_is_fixed_by_the_seed() {
let order = |seed: u64| {
+1 -1
View File
@@ -756,7 +756,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back |
| LRC-SS-TITLES | Slideshow titles | P2 | 🟡 | `cmd:slideshow.titles`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_title.rs`, `path:crates/ui-egui/src/panels/slide_titles.rs` | Intro screen and ending screen, each on or off (library preference, `slideshow.titles`; Settings ▸ Slideshow ▸ Titles): a background colour and, centred on it, the identity plate of the Overlays section (its text or image; Add Identity Plate) or the screen's own line of text, in the plate's colour or an override colour, at a scale (height as a share of the slide's shorter side; text wider than the slide shrinks to fit), for its own duration (default: the slide duration). Titles are items of the playback timeline: composed off the UI thread by the same composer at the screen's size, prepared ahead, waited for and faded like slides; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; ←/→ step across them; they never change the selection and the control bar shows Intro / End instead of n / N. `slideshow.preview {title}` renders a screen; the Settings preview shows Photo / Intro / Ending. An empty view still refuses to play ("no photos to show"). Missing: Classic's exact behaviour with Repeat on is not verified (we never show the ending, and show the intro only at the start, while repeating), the Identity Plate Editor's styled text (Inter SemiBold only), override colour leaves an image plate's colours alone, the Slideshow module's Titles panel (settings live in Settings ▸ Slideshow). Slideshow export (LRC-SS-EXPORT) includes them: a PDF page / JPEG each, and in video they hold and fade like slides |
| LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | |
| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on. Intro and ending screens play as items of the same timeline (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) |
| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held), manual slideshow (`manual`: a slide, the intro or the ending stays up until ←/→ or the control bar changes it; exports keep the slide duration). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, choosing the screen to play on. Intro and ending screens play as items of the same timeline (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) |
| LRC-SS-EXPORT | Slideshow export | P2 | 🟡 | `cmd:slideshow.export`, `cmd:slideshow.exportStatus`, `cmd:dialog.slideshowExport`, `cmd:app.slideshowExport`, `path:crates/engine/src/slideshow_export.rs`, `path:crates/codecs/src/video.rs`, `path:crates/ui-egui/src/slideshow_export.rs` | View ▸ Export Slideshow… (and Export PDF / JPEG / Video in Settings ▸ Slideshow): the photos in view as their composed slides (layout, overlays) between the intro and ending screens that are on, in view order. **PDF**: one page per slide at 1024 / 1920 / 3840 px (any 64–8192 by command; page = slide pixels in points, aspect from the aspect preview, "screen" = 16:9), JPEG quality, each page carrying the slide duration and a fade transition for presentation mode, "Automatically show full screen" (`/PageMode /FullScreen`). **JPEG**: `<name>-001.jpg`… in a new folder. **Video**: AV1 (rav1e, fast preset) in MP4 or WebM written by our own muxers, 720p / 1080p (64–2160 by command), 24 / 30 fps (25 / 60 by command); each slide held for its duration (one long sample: variable frame rate) and fades rendered as blended frames (crossfade or through the fade colour); at most 2 hours. Runs in the background with a row in the activity stack (frames or slides done, ✕ cancels); files are written as `.part` and renamed when complete, so a cancelled or failed export leaves nothing; a photo that can't be rendered is skipped with a warning. Missing: H.264 (Classic's codec; patents, no pure-Rust encoder), audio / music in the video (LRC-SS-MUSIC), random order and pan & zoom in exports, the PDF's per-page fades follow the viewer (Acrobat), export presets, Classic's export dialog sizes beyond these, the web build (no export) |
| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | ✅ | `cmd:dialog.print`, `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/ui-egui/src/print_view.rs` | File → Print… (⌘P): Single Image, Contact Sheet, Picture Package and Custom Package, one page preview each; also `print.*` commands for the CLI, control channel and MCP; the older File → Contact Sheet PDF stays |
| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ✅ | `cmd:print.set`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | Zoom to Fill Frame, Rotate to Fit, Photo Border (width, colour; package styles) and Inner Stroke (width in points, colour) in the dialog, drawn in the preview and in the PDF; pixel-tested |
+2 -2
View File
@@ -7,7 +7,7 @@ next to a live preview of the selected photo's slide, and View ▸ Slideshow (
| Part | Commands | Code |
|---|---|---|
| Playback: slide and fade durations, fade colour, random order, repeat, preparing slides ahead | `slideshow.playback`, `view.slideshow` | `crates/engine/src/slideshow.rs` (the timeline), `crates/ui-egui/src/panels/slideshow.rs` |
| Playback: slide and fade durations, fade colour, random order, repeat, preparing slides ahead, manual slideshow | `slideshow.playback`, `view.slideshow` | `crates/engine/src/slideshow.rs` (the timeline), `crates/ui-egui/src/panels/slideshow.rs` |
| Layout, options, backdrop: margins, aspect preview, zoom to fill, stroke, shadow, colour, colour wash, background photo | `slideshow.layout` | `crates/slideshow` (the composer, no UI), `crates/engine/src/slide.rs` |
| Overlays: identity plate, rating stars, text with tokens, watermark | `slideshow.overlays` | `crates/engine/src/slide_overlay.rs` |
| Intro and ending screens | `slideshow.titles` | `crates/engine/src/slide_title.rs` |
@@ -23,7 +23,7 @@ the same pixels through the same composer.
View ▸ Export Slideshow… (or Export PDF / JPEG / Video at the end of Settings ▸ Slideshow) chooses a format and size,
then asks where to write. The slides are the photos in view, in view order, between the intro and ending screens that
are on; random order and repeat are playback choices and don't apply. The slide aspect is the Layout's aspect preview
are on; random order, repeat and manual slideshow are playback choices and don't apply. The slide aspect is the Layout's aspect preview
(Screen exports 16:9).
- **PDF** — one page per slide, its picture a JPEG at the chosen quality, the page as many points as the slide has