class PresenterView
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 = Live::Element.unique_id, data = {}, controller: nil)
Initialize a new presenter view.
Signature
-
parameter
idString The unique element identifier.
-
parameter
dataHash The element data attributes.
-
parameter
controllerPresentationController | Nil The shared presentation controller.
Implementation
def initialize(id = Live::Element.unique_id, data = {}, controller: nil)
super(id, data)
@controller = controller
@clock_task = 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.update!
end
def update_timing!
Push an update to just the timing section.
Implementation
def update_timing!
replace(".timing") do |builder|
render_timing(builder, @controller.current_slide)
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!
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 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)
progress = (@controller.slide_progress * 100).round(1)
next_slide = @controller.next_slide
builder.tag(:div, class: "timing", style: "--slide-progress: #{progress}%") do
pacing = @controller.pacing
pacing_class = case pacing
when :behind then "behind"
when :ahead then "ahead"
else "on-time"
end
builder.tag(:div, class: "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"
end
builder.text(label)
end
builder.tag(:button,
class: "pause-button",
onClick: forward_event(action: "reset")
) do
builder.text("↺ Reset")
end
builder.tag(:span, class: "elapsed") do
builder.text("Elapsed: #{format_duration(@controller.clock.elapsed)}")
end
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
if slide
builder.tag(:span, class: "slide-duration") do
builder.text("Slide: #{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: "controls") do
builder.tag(:button,
onClick: forward_event(action: "previous")
) do
builder.text("← Previous")
end
builder.tag(:span, class: "slide-info") do
builder.text("Slide #{@controller.current_index + 1} of #{@controller.slide_count}")
if slide
builder.text(" · ")
builder.tag(:code, class: "slide-path") do
builder.text(File.basename(slide.path))
end
if editor_url = editor_url_for(slide.path)
builder.tag(:a, href: editor_url, class: "edit-link") do
builder.text("✎")
end
end
end
end
builder.tag(:button,
onClick: forward_event(action: "next")
) do
builder.text("Next →")
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") 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") 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