class PresenterView < SlideView
- Inherits from
SlideView: #render_slide_path, #render_slide!
The presenter-facing view with notes, timing, and slide previews.
Shows the current slide, next slide preview, presenter notes, timing controls, and pacing indicators. Updates the timing display every second via a background task.
Definitions
def initialize(id, data, controller:)
Initialize a new presenter view.
Signature
-
parameter
idString The unique element identifier.
-
parameter
dataHash The element data attributes.
-
parameter
controllerPresentationController The shared presentation controller.
Implementation
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
def bind(page)
Bind this view to a page and start the timing update loop.
Signature
-
parameter
pageLive::Page The page this view is bound to.
Implementation
def bind(page)
super
@controller.add_listener(self)
# Update only the timing section every second.
@clock_task = Async do
while true
update_timing!
sleep 1
end
end
end
def close
Close this view and stop the timing update loop.
Implementation
def close
@clock_task&.stop
@controller.remove_listener(self)
super
end
def slide_changed!
Called by the controller when the slide changes.
Implementation
def slide_changed!
self.render_slide!
end
def update_timing!
Update the timing section and the Next tooltip when the clock state changes. Leave unchanged, paused controls in place to preserve keyboard focus.
Implementation
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
def handle(event)
Handle an event from the client.
Signature
-
parameter
eventHash The event data with
:detailcontaining the action.
Implementation
def handle(event)
action = event.dig(:detail, :action)
case action
when "next"
@controller.advance!
when "previous"
@controller.retreat!
when "pause"
if !@controller.clock.started?
@controller.clock.start!
elsif @controller.clock.paused?
@controller.clock.resume!
else
@controller.clock.pause!
end
@controller.save_state!
update_timing! if @page
when "reset"
@controller.reset_timer!
when "reload"
@controller.reload!
when "jump"
if index = event.dig(:detail, :index)
@controller.go_to(index.to_i)
end
end
end
def editor_url_for(path, line = 1)
Generate an editor URL for the given file path.
Signature
-
parameter
pathString The file path.
-
parameter
lineInteger The line number.
-
returns
String | Nil The editor URL, or
nil.
Implementation
def editor_url_for(path, line = 1)
Editor.url_for(path, line)
end
def timer_action_hint
Describe the actual effect of the outgoing slide's timer metadata.
Signature
-
returns
String | Nil The hint, or
nilwhen there is no timer action or next slide.
Implementation
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
def render_timing(builder, slide)
Render the timing bar with controls, elapsed/remaining time, and pacing.
Signature
-
parameter
builderXRB::Builder The HTML builder.
-
parameter
slideSlide | Nil The current slide.
Implementation
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"
when :ahead then "ahead"
when :on_time then "on-time"
end
builder.tag(:div, class: "toolbar timing-info #{pacing_class}") do
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
end
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
builder.text("Elapsed: #{format_duration(@controller.clock.elapsed)}")
end
if pacing
builder.tag(:span, class: "remaining") do
builder.text("Remaining: #{format_duration(@controller.time_remaining)}")
end
builder.tag(:span, class: "pacing-indicator") do
indicator = case pacing
when :behind then "⏩ Speed up"
when :ahead then "⏪ Slow down"
else "✓ On time"
end
builder.text(indicator)
end
end
if slide
builder.tag(:span, class: "slide-duration") do
builder.text("Duration: #{format_duration(slide.duration)}")
end
end
# Speaker display — only shown when front matter includes a `speaker` key.
if (current_speaker = slide&.speaker)
builder.tag(:span, class: "speaker-info") do
builder.tag(:span, class: "speaker-label"){builder.text("🎤")}
builder.text(" #{current_speaker}")
# Show upcoming speaker only when they differ from the current one.
if (next_speaker = next_slide&.speaker) && next_speaker != current_speaker
builder.tag(:span, class: "next-speaker") do
builder.text(" → #{next_speaker}")
end
end
end
end
end
end
end
def format_duration(seconds)
Format a duration in seconds as M:SS.
Signature
-
parameter
secondsNumeric The duration in seconds.
-
returns
String The formatted duration string.
Implementation
def format_duration(seconds)
seconds = seconds.to_i
minutes = seconds / 60
secs = seconds % 60
format("%d:%02d", minutes, secs)
end
def render(builder)
Render the full presenter view.
Signature
-
parameter
builderXRB::Builder The HTML builder.
Implementation
def render(builder)
slide = @controller.current_slide
next_slide = @controller.next_slide
builder.tag(:div, class: "presenter") do
# Controls bar
builder.tag(:div, class: "toolbar controls") do
builder.tag(:button,
onClick: forward_event(action: "previous")
) do
builder.text("← Previous")
end
render_next_button(builder)
builder.tag(:span, class: "slide-info") do
builder.tag(:span, class: "slide-position") do
builder.text("Slide #{@controller.current_index + 1} of #{@controller.slide_count}")
end
if slide
render_slide_path(builder, slide.path)
if editor_url = editor_url_for(slide.source_path)
builder.tag(:a, href: editor_url, class: "edit-link") do
builder.text("✎")
end
end
end
end
# Jump-to dropdown for marked slides
markers = []
@controller.slides.each_with_index do |s, i|
if s.marker
markers << [i, s.marker]
end
end
unless markers.empty?
builder.tag(:select,
class: "jump-to",
"data-live-id": @id
) do
builder.tag(:option, value: "", disabled: true, selected: true) do
builder.text("Jump to…")
end
markers.each do |index, label|
builder.tag(:option, value: index) do
builder.text(label)
end
end
end
end
builder.tag(:button,
onClick: forward_event(action: "reload"),
class: "reload"
) do
builder.text("↻ Reload")
end
end
# Slide previews
builder.tag(:div, class: "previews") do
# Current slide
builder.tag(:div, class: "preview current-preview") do
builder.tag(:h3){builder.text("Current")}
builder.tag(:div, class: "preview-frame slide-viewport") do
@preview_renderer.render(builder, slide)
end
end
# Next slide
builder.tag(:div, class: "preview next-preview") do
builder.tag(:h3){builder.text("Next")}
builder.tag(:div, class: "preview-frame slide-viewport") do
if next_slide
@preview_renderer.render(builder, next_slide)
else
builder.tag(:div, class: "no-slide") do
builder.text("End of presentation")
end
end
end
end
end
# Timing
render_timing(builder, slide)
# Presenter notes
builder.tag(:div, class: "notes") do
builder.tag(:h3){builder.text("Notes")}
builder.tag(:div, class: "notes-content") do
if notes = slide&.notes
builder.raw(notes.to_html)
else
builder.tag(:p, class: "no-notes"){builder.text("No presenter notes for this slide.")}
end
end
end
end
end