Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,31 @@ The presenter view at `/presenter` provides:

- **Current and next slide previews** — see what's coming without switching windows.
- **Presenter notes** — notes from the slide's `---` separator section.
- **Timer controls** — Start, Pause, Resume, and Reset buttons.
- **Timer controls** — Start when ready, Pause while running, and Resume or Reset while paused.
- **Pacing indicator** — shows whether you're on time, ahead, or behind based on per-slide `duration` metadata.
- **Progress bar** — visual indicator of time consumed for the current slide.
- **Reload button** — reload slides from disk without restarting the server.

### Timer Controls

Pause the timer before changing its elapsed time, whether you are rehearsing a section or preparing for another presentation.

A **ready** timer is waiting to start from its current timestamp. It can be ready at `0:00` or at a later position after Reset.

| Timer state | Available controls |
|---|---|
| Ready | **Start** begins timing, or **Auto-start** indicates that advancing will begin timing. |
| Running | **Pause** freezes elapsed time. |
| Paused | **Resume** continues timing, and **Reset** prepares the current slide to begin again. |

**Reset** always sets elapsed time to the sum of durations before the current slide. It normally leaves the timer paused; press **Resume** when ready to continue. On a slide with `timer: start`, Reset returns the timer to the ready state at that same timestamp, so advancing starts from the slide's expected time. Reset leaves the current slide unchanged and is only available while paused.

After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts timing from its expected timestamp, regardless of its position in the deck. For example, a waiting slide after five minutes of allocated content resets to `5:00`, waits, and starts from `5:00` when you advance. The timestamp and ready state are also preserved when saving and restoring the presentation.

For slides with recognized timer metadata and a following slide, the **Next** tooltip describes what advancing will actually do. For example, a manually paused `timer: start` slide shows “Timer is paused. Advancing will leave it paused.” After Reset, the tooltip changes to “Advancing will start the timer.” The tooltip also reflects pause and resume actions, including when they would leave the clock unchanged.

While the timer is ready, an **Auto-start** indicator replaces **Start** on a `timer: start` slide with a following slide. Its play icon gently pulses, and its tooltip says “Advancing will start the timer.” Advance to begin timing; **Pause** and **Resume** are available while running and paused respectively. The animation respects reduced-motion preferences.

### Starting the Timer from a Title Slide

A title slide can stay on screen while the audience settles. Add `timer: start` to its frontmatter to start the presentation timer when you advance to the next slide:
Expand All @@ -252,17 +272,17 @@ timer: start
We'll begin shortly.
```

The timer stays stopped while the title is displayed. Advancing from it in `/presenter` or `/display` starts the timer before showing the next slide. Setting `duration: 0` excludes the waiting slide from the expected presentation duration and pacing calculations. The `title` template itself does not control the timer.
The timer stays ready while the title is displayed. Advancing from it in `/presenter` or `/display` starts the timer before showing the next slide. Setting `duration: 0` excludes the waiting slide from the expected presentation duration and pacing calculations. The `title` template itself does not control the timer.

Durations are read as floating-point seconds, including numeric strings. Negative, invalid, or non-finite values are treated as `0.0`. An unspecified or null duration defaults to `0.0` seconds, meaning no time has been allocated to that slide. Set explicit durations, or apply recorded narration durations, to establish a pacing schedule. When the total allocated duration is zero, the presenter shows elapsed time without pacing indicators, a progress bar, or a remaining-time estimate.

The `timer` field supports these actions:

| Value | Effect when advancing from this slide |
|---|---|
| `start` | Starts the timer only if it has never started. Revisiting the slide does not reset elapsed time or resume a manually paused timer. |
| `pause` | Pauses the timer, preserving elapsed time. Has no effect before the timer starts. |
| `resume` | Resumes a started timer, preserving elapsed time. Has no effect before the timer starts or while it is already running. |
| `start` | Starts a ready timer from its current timestamp. Revisiting the slide does not reset elapsed time or resume a manually paused timer. |
| `pause` | Pauses the timer, preserving elapsed time. Has no effect while ready. |
| `resume` | Resumes a paused timer, preserving elapsed time. Has no effect while ready or already running. |

For a break, put `timer: pause` on the slide immediately before the break slide, and `timer: resume` on the break slide itself. Give the break slide `duration: 0` to exclude the break from pacing calculations. Advancing into the break pauses timing; advancing out resumes it.

Expand Down
31 changes: 25 additions & 6 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,31 @@ The presenter view at `/presenter` provides:

- **Current and next slide previews** — see what's coming without switching windows.
- **Presenter notes** — notes from the slide's `---` separator section.
- **Timer controls** — Start, Pause, Resume, and Reset buttons.
**Reset** re-aligns elapsed time with the expected time for the current slide. On the first slide it stops the timer entirely, so that it can be started again, e.g. by `timer: start`.
- **Timer controls** — Start when ready, Pause while running, and Resume or Reset while paused.
- **Pacing indicator** — shows whether you're on time, ahead, or behind based on per-slide `duration` metadata.
- **Progress bar** — visual indicator of time consumed for the current slide.
- **Reload button** — reload slides from disk without restarting the server.

### Timer Controls

Pause the timer before changing its elapsed time, whether you are rehearsing a section or preparing for another presentation.

A **ready** timer is waiting to start from its current timestamp. It can be ready at `0:00` or at a later position after Reset.

| Timer state | Available controls |
|---|---|
| Ready | **Start** begins timing, or **Auto-start** indicates that advancing will begin timing. |
| Running | **Pause** freezes elapsed time. |
| Paused | **Resume** continues timing, and **Reset** prepares the current slide to begin again. |

**Reset** always sets elapsed time to the sum of durations before the current slide. It normally leaves the timer paused; press **Resume** when ready to continue. On a slide with `timer: start`, Reset returns the timer to the ready state at that same timestamp, so advancing starts from the slide's expected time. Reset leaves the current slide unchanged and is only available while paused.

After a rehearsal, return to your waiting slide, pause, and press **Reset**. Advancing from that `timer: start` slide starts timing from its expected timestamp, regardless of its position in the deck. For example, a waiting slide after five minutes of allocated content resets to `5:00`, waits, and starts from `5:00` when you advance. The timestamp and ready state are also preserved when saving and restoring the presentation.

For slides with recognized timer metadata and a following slide, the **Next** tooltip describes what advancing will actually do. For example, a manually paused `timer: start` slide shows “Timer is paused. Advancing will leave it paused.” After Reset, the tooltip changes to “Advancing will start the timer.” The tooltip also reflects pause and resume actions, including when they would leave the clock unchanged.

While the timer is ready, an **Auto-start** indicator replaces **Start** on a `timer: start` slide with a following slide. Its play icon gently pulses, and its tooltip says “Advancing will start the timer.” Advance to begin timing; **Pause** and **Resume** are available while running and paused respectively. The animation respects reduced-motion preferences.

### Starting the Timer from a Title Slide

A title slide can stay on screen while the audience settles. Add `timer: start` to its frontmatter to start the presentation timer when you advance to the next slide:
Expand All @@ -253,17 +272,17 @@ timer: start
We'll begin shortly.
```

The timer stays stopped while the title is displayed. Advancing from it in `/presenter` or `/display` starts the timer before showing the next slide. Setting `duration: 0` excludes the waiting slide from the expected presentation duration and pacing calculations. The `title` template itself does not control the timer.
The timer stays ready while the title is displayed. Advancing from it in `/presenter` or `/display` starts the timer before showing the next slide. Setting `duration: 0` excludes the waiting slide from the expected presentation duration and pacing calculations. The `title` template itself does not control the timer.

Durations are read as floating-point seconds, including numeric strings. Negative, invalid, or non-finite values are treated as `0.0`. An unspecified or null duration defaults to `0.0` seconds, meaning no time has been allocated to that slide. Set explicit durations, or apply recorded narration durations, to establish a pacing schedule. When the total allocated duration is zero, the presenter shows elapsed time without pacing indicators, a progress bar, or a remaining-time estimate.

The `timer` field supports these actions:

| Value | Effect when advancing from this slide |
|---|---|
| `start` | Starts the timer only if it has never started. Revisiting the slide does not reset elapsed time or resume a manually paused timer. Press **Reset** on the first slide to stop the timer so it starts again. |
| `pause` | Pauses the timer, preserving elapsed time. Has no effect before the timer starts. |
| `resume` | Resumes a started timer, preserving elapsed time. Has no effect before the timer starts or while it is already running. |
| `start` | Starts a ready timer from its current timestamp. Revisiting the slide does not reset elapsed time or resume a manually paused timer. |
| `pause` | Pauses the timer, preserving elapsed time. Has no effect while ready. |
| `resume` | Resumes a paused timer, preserving elapsed time. Has no effect while ready or already running. |

For a break, put `timer: pause` on the slide immediately before the break slide, and `timer: resume` on the break slide itself. Give the break slide `duration: 0` to exclude the break from pacing calculations. Advancing into the break pauses timing; advancing out resumes it.

Expand Down
33 changes: 15 additions & 18 deletions lib/presently/clock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@
module Presently
# A simple clock that tracks elapsed time with start, pause, resume, and reset.
#
# The clock accumulates elapsed time while running and freezes it when paused.
# A ready clock waits to start at its current elapsed time. It accumulates time
# while running and freezes it when paused.
class Clock
# Initialize a new clock in the stopped state.
# Initialize a new clock in the ready state at zero.
def initialize
@elapsed = 0
@started = false
@running = false
@last_tick = nil
end

# Whether the clock has been started at least once.
# Whether the clock is running or paused; false when ready.
# @returns [Boolean]
def started?
@started
Expand All @@ -35,7 +36,7 @@ def paused?
end

# The total elapsed time in seconds.
# Includes time accumulated up to now if running, or frozen time if paused.
# Includes time accumulated up to now if running, or frozen time if paused or ready.
# @returns [Numeric] The elapsed time in seconds.
def elapsed
if @running
Expand Down Expand Up @@ -78,20 +79,16 @@ def resume!
@last_tick = Time.now
end

# Reset the elapsed time to the given value.
# If running, continues from the new value. If paused, sets the frozen value.
# @parameter elapsed [Numeric] The new elapsed time in seconds.
def reset!(elapsed = 0)
@elapsed = elapsed
@last_tick = Time.now if @running
end

# Stop the clock and return it to its initial, never-started state.
def stop!
@elapsed = 0
@started = false
@running = false
@last_tick = nil
# Reset the clock to its initial, ready state, or set its elapsed time.
# A numeric value preserves whether the clock is running; a ready clock becomes paused.
# Pass `started: false` to make the clock ready at the given elapsed time.
# @parameter elapsed [Numeric | Nil] The elapsed time in seconds, or `nil` to clear the clock.
# @parameter started [Boolean] Whether the clock is running or paused at the reset position; false makes it ready.
def reset!(elapsed = nil, started: !elapsed.nil?)
@elapsed = elapsed || 0
@started = started
@running = @started && @running
@last_tick = @running ? Time.now : nil
end
end
end
15 changes: 6 additions & 9 deletions lib/presently/presentation_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,13 @@ def slide_progress
end
end

# Reset the timer so that elapsed time matches the expected time for the current slide.
# On the first slide, stop the timer entirely so that it can be started again, e.g. by a `timer: start` action.
# Reset a paused timer to begin the current slide again.
# All slides reset to their expected start time. A `timer: start` slide becomes
# ready to start when advancing; other slides remain paused.
def reset_timer!
if @current_index.zero?
@clock.stop!
else
@clock.reset!(@presentation.expected_time_at(@current_index))
end
return unless @clock.paused? && current_slide

@clock.reset!(@presentation.expected_time_at(@current_index), started: current_slide.timer != "start")

notify_listeners!
end
Expand All @@ -139,8 +138,6 @@ def pacing
# The estimated time remaining in the presentation.
# @returns [Numeric] The remaining time in seconds.
def time_remaining
return total_duration unless @clock.started?

expected_remaining = @presentation.expected_time_at(slide_count) - @clock.elapsed

[expected_remaining, 0].max
Expand Down
113 changes: 91 additions & 22 deletions lib/presently/presenter_view.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def initialize(id, data, controller:)
super(id, data)
@controller = controller
@clock_task = nil
@timing_state = nil
@preview_renderer = SlideRenderer.new(css_class: "slide preview-slide", templates: controller.templates)
end

Expand Down Expand Up @@ -51,11 +52,22 @@ def slide_changed!
self.render_slide!
end

# Push an update to just the timing section.
# Update the timing section and the Next tooltip when the clock state changes.
# Leave unchanged, paused controls in place to preserve keyboard focus.
def update_timing!
clock = @controller.clock
state_changed = @timing_state != [clock.started?, clock.running?]
return unless clock.running? || state_changed

replace(".timing") do |builder|
render_timing(builder, @controller.current_slide)
end

if state_changed
replace(".next-button") do |builder|
render_next_button(builder)
end
end
end

# Handle an event from the client.
Expand All @@ -77,6 +89,7 @@ def handle(event)
@controller.clock.pause!
end
@controller.save_state!
update_timing! if @page
when "reset"
@controller.reset_timer!
when "reload"
Expand All @@ -96,13 +109,53 @@ def editor_url_for(path, line = 1)
Editor.url_for(path, line)
end

# Describe the actual effect of the outgoing slide's timer metadata.
# @returns [String | Nil] The hint, or `nil` when there is no timer action or next slide.
def timer_action_hint
return unless @controller.next_slide

clock = @controller.clock
case @controller.current_slide.timer
when "start"
return "Advancing will start the timer." unless clock.started?
when "pause"
return "Advancing will pause the timer." if clock.running?
when "resume"
return "Advancing will resume the timer." if clock.paused?
else
return
end

if clock.running?
"Advancing will leave the timer running."
elsif clock.paused?
"Timer is paused. Advancing will leave it paused."
else
"Advancing will leave the timer stopped."
end
end

# Render the Next button with a tooltip describing its timer action.
# @parameter builder [XRB::Builder] The HTML builder.
def render_next_button(builder)
builder.tag(:button,
class: "next-button",
title: timer_action_hint,
onClick: forward_event(action: "next")
) do
builder.text("Next →")
end
end

# Render the timing bar with controls, elapsed/remaining time, and pacing.
# @parameter builder [XRB::Builder] The HTML builder.
# @parameter slide [Slide | Nil] The current slide.
def render_timing(builder, slide)
@timing_state = [@controller.clock.started?, @controller.clock.running?]
pacing = @controller.pacing
progress = pacing ? (@controller.slide_progress * 100).round(1) : 0.0
next_slide = @controller.next_slide
wait_for_advance = !@controller.clock.started? && slide&.timer == "start" && !!next_slide
builder.tag(:div, class: "timing", style: "--slide-progress: #{progress}%") do
pacing_class = case pacing
when :behind then "behind"
Expand All @@ -111,25 +164,45 @@ def render_timing(builder, slide)
end

builder.tag(:div, class: "toolbar timing-info #{pacing_class}") do
builder.tag(:button,
class: "pause-button",
onClick: forward_event(action: "pause")
) do
label = if !@controller.clock.started?
"▶ Start"
elsif @controller.clock.paused?
"▶ Resume"
else
"⏸ Pause"
if wait_for_advance
builder.tag(:span,
class: "auto-start",
role: "status",
title: "Advancing will start the timer."
) do
builder.tag(:span, class: "auto-start-icon", "aria-hidden": "true"){builder.text("▶")}
builder.text("Auto-start")
end
else
builder.tag(:button,
class: "pause-button",
onClick: forward_event(action: "pause")
) do
label = if !@controller.clock.started?
"▶ Start"
elsif @controller.clock.paused?
"▶ Resume"
else
"⏸ Pause"
end
builder.text(label)
end
builder.text(label)
end

builder.tag(:button,
class: "pause-button",
onClick: forward_event(action: "reset")
) do
builder.text("↺ Reset")
if @controller.clock.paused? && slide
title = if slide.timer == "start"
"Reset to this slide's timestamp and wait to start"
else
"Reset to this slide's timestamp and stay paused"
end

builder.tag(:button,
class: "pause-button",
title: title,
onClick: forward_event(action: "reset")
) do
builder.text("↺ Reset")
end
end

builder.tag(:span, class: "elapsed") do
Expand Down Expand Up @@ -200,11 +273,7 @@ def render(builder)
builder.text("← Previous")
end

builder.tag(:button,
onClick: forward_event(action: "next")
) do
builder.text("Next →")
end
render_next_button(builder)

builder.tag(:span, class: "slide-info") do
builder.tag(:span, class: "slide-position") do
Expand Down
Loading
Loading