presently 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/bake/presently/export.rb +187 -45
  4. data/bake/presently/recordings.rb +72 -0
  5. data/context/animating-slides.md +25 -1
  6. data/lib/presently/application.rb +157 -30
  7. data/lib/presently/display_view.rb +7 -7
  8. data/lib/presently/environment/application.rb +17 -1
  9. data/lib/presently/export.rb +15 -13
  10. data/lib/presently/export.xrb +4 -0
  11. data/lib/presently/home_view.rb +65 -0
  12. data/lib/presently/page.rb +29 -22
  13. data/lib/presently/playback.rb +71 -0
  14. data/lib/presently/playback.xrb +59 -0
  15. data/lib/presently/presentation.rb +5 -0
  16. data/lib/presently/presenter_view.rb +6 -6
  17. data/lib/presently/recording_view.rb +158 -0
  18. data/lib/presently/recordings/normalizer.rb +183 -0
  19. data/lib/presently/recordings.rb +100 -0
  20. data/lib/presently/slide.rb +35 -3
  21. data/lib/presently/slide_assets.rb +101 -0
  22. data/lib/presently/slide_renderer.rb +2 -1
  23. data/lib/presently/slide_view.rb +23 -0
  24. data/lib/presently/stylesheet.rb +112 -0
  25. data/lib/presently/version.rb +1 -1
  26. data/lib/presently.rb +5 -0
  27. data/public/{code-focus.js → _components/@socketry/presently/Presently/CodeFocus.js} +1 -0
  28. data/public/{slide-scripts.js → _components/@socketry/presently/Presently/Scripts.js} +1 -1
  29. data/public/{slide.js → _components/@socketry/presently/Presently/Slide.js} +70 -13
  30. data/public/_components/@socketry/presently/Presently/SlideRendering.js +109 -0
  31. data/public/_components/@socketry/presently/Presently.js +4 -0
  32. data/public/_components/@socketry/syntax/Syntax/CodeElement.js +116 -37
  33. data/public/_components/@socketry/syntax/Syntax/Language.js +5 -2
  34. data/public/_components/@socketry/syntax/Syntax/Match.js +8 -2
  35. data/public/_components/@socketry/syntax/Syntax/Rule.js +21 -5
  36. data/public/_components/@socketry/syntax/Syntax.js +2 -2
  37. data/public/_components/@socketry/syntax/package.json +6 -5
  38. data/public/_components/@socketry/syntax/readme.md +7 -1
  39. data/public/_components/@socketry/syntax/themes/base/syntax.css +11 -0
  40. data/public/_components/@socketry/syntax/themes/theming.md +11 -0
  41. data/public/_static/index.css +381 -0
  42. data/public/application.js +29 -64
  43. data/public/export.js +1 -3
  44. data/public/playback.js +266 -0
  45. data/public/recorder.js +282 -0
  46. data/readme.md +38 -0
  47. data/releases.md +8 -0
  48. data.tar.gz.sig +0 -0
  49. metadata +34 -8
  50. metadata.gz.sig +0 -0
  51. data/bake/presently/node.rb +0 -47
  52. data/lib/presently/page.xrb +0 -32
@@ -3,7 +3,7 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
- require "live"
6
+ require_relative "slide_view"
7
7
  require_relative "slide_renderer"
8
8
 
9
9
  module Presently
@@ -11,15 +11,15 @@ module Presently
11
11
  #
12
12
  # Connects to the {PresentationController} as a listener and updates
13
13
  # whenever the slide changes. Pushes the current state on WebSocket reconnect.
14
- class DisplayView < Live::View
14
+ class DisplayView < SlideView
15
15
  # Initialize a new display view.
16
16
  # @parameter id [String] The unique element identifier.
17
17
  # @parameter data [Hash] The element data attributes.
18
- # @parameter controller [PresentationController | Nil] The shared presentation controller.
19
- def initialize(id = Live::Element.unique_id, data = {}, controller: nil)
18
+ # @parameter controller [PresentationController] The shared presentation controller.
19
+ def initialize(id, data, controller:)
20
20
  super(id, data)
21
21
  @controller = controller
22
- @slide_renderer = SlideRenderer.new(css_class: "slide current", templates: controller&.templates)
22
+ @slide_renderer = SlideRenderer.new(css_class: "slide current", templates: controller.templates)
23
23
  end
24
24
 
25
25
  # Bind this view to a page and register as a listener.
@@ -28,7 +28,7 @@ module Presently
28
28
  def bind(page)
29
29
  super
30
30
  @controller.add_listener(self)
31
- self.update!
31
+ self.render_slide!(transition: @controller.current_slide&.transition)
32
32
  end
33
33
 
34
34
  # Close this view and unregister as a listener.
@@ -39,7 +39,7 @@ module Presently
39
39
 
40
40
  # Called by the controller when the slide changes.
41
41
  def slide_changed!
42
- self.update!
42
+ self.render_slide!(transition: @controller.current_slide&.transition)
43
43
  end
44
44
 
45
45
  # Handle an event from the client.
@@ -30,6 +30,18 @@ module Presently
30
30
  [File.expand_path("templates", self.root)].select{|d| File.directory?(d)}
31
31
  end
32
32
 
33
+ # The root directory where per-slide narration recordings are stored.
34
+ # @returns [String] Absolute path to the recording root.
35
+ def recordings_root
36
+ File.expand_path("audio", self.root)
37
+ end
38
+
39
+ # The root directory containing normalized narration used for playback.
40
+ # @returns [String] Absolute path to the playback recording root.
41
+ def playback_recordings_root
42
+ File.expand_path("audio-normalized", self.root)
43
+ end
44
+
33
45
  # The application class to use.
34
46
  # @returns [Class] The Presently application class.
35
47
  def application
@@ -42,6 +54,8 @@ module Presently
42
54
  application = self.application
43
55
  slides_root = self.slides_root
44
56
  templates_roots = self.templates_roots
57
+ recordings_root = self.recordings_root
58
+ playback_recordings_root = self.playback_recordings_root
45
59
  root = self.root
46
60
 
47
61
  ::Protocol::HTTP::Middleware.build do |builder|
@@ -56,7 +70,9 @@ module Presently
56
70
 
57
71
  builder.use application,
58
72
  slides_root: slides_root,
59
- templates_roots: templates_roots
73
+ templates_roots: templates_roots,
74
+ recordings_root: recordings_root,
75
+ playback_recordings_root: playback_recordings_root
60
76
  end
61
77
  end
62
78
  end
@@ -3,7 +3,6 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
- require "uri"
7
6
  require "xrb/template"
8
7
  require "xrb/markup"
9
8
 
@@ -37,24 +36,22 @@ module Presently
37
36
 
38
37
  TEMPLATE = XRB::Template.load_file(File.expand_path("export.xrb", __dir__))
39
38
 
40
- # Parse export options from a URL query string.
41
- # @parameter query [String | Nil] The raw query string, e.g. `"notes=true&speaker=true"`.
39
+ # Extract export options from decoded query parameters.
40
+ # @parameter parameters [Hash] The decoded query parameters.
42
41
  # @returns [Hash] Options suitable for passing to {.new}.
43
- def self.options_from_query(query)
44
- return {} unless query
45
-
46
- params = URI.decode_www_form(query).to_h
42
+ def self.options_from_parameters(parameters)
43
+ return {} if parameters.empty?
47
44
 
48
45
  page_size = PageSize.new(
49
- slide_width_px: (params["slide_width_px"] || PageSize::DEFAULT.slide_width_px).to_i,
50
- slide_height_px: (params["slide_height_px"] || PageSize::DEFAULT.slide_height_px).to_i,
51
- notes_height_px: (params["notes_height_px"] || PageSize::DEFAULT.notes_height_px).to_i,
46
+ slide_width_px: (parameters["slide_width_px"] || PageSize::DEFAULT.slide_width_px).to_i,
47
+ slide_height_px: (parameters["slide_height_px"] || PageSize::DEFAULT.slide_height_px).to_i,
48
+ notes_height_px: (parameters["notes_height_px"] || PageSize::DEFAULT.notes_height_px).to_i,
52
49
  )
53
50
 
54
51
  {
55
- notes: params["notes"] != "false",
56
- speaker: params["speaker"] != "false",
57
- timing: params["timing"] != "false",
52
+ notes: parameters["notes"] != "false",
53
+ speaker: parameters["speaker"] != "false",
54
+ timing: parameters["timing"] != "false",
58
55
  page_size: page_size,
59
56
  }
60
57
  end
@@ -85,6 +82,11 @@ module Presently
85
82
  # @attribute [Boolean] Whether slide timing is included.
86
83
  attr :timing
87
84
 
85
+ # @returns [Array(Stylesheet)] The ordered presentation stylesheets.
86
+ def stylesheets
87
+ @presentation.stylesheets
88
+ end
89
+
88
90
  # Render a single slide to an HTML string.
89
91
  # @parameter slide [Slide] The slide to render.
90
92
  # @returns [XRB::MarkupString]
@@ -6,10 +6,14 @@
6
6
  <link rel="stylesheet" href="/_static/index.css" type="text/css" />
7
7
  <link rel="stylesheet" href="/_static/custom.css" type="text/css" />
8
8
  <link rel="stylesheet" href="/_components/@socketry/syntax/themes/base/syntax.css" type="text/css" />
9
+ <?r self.stylesheets.each do |stylesheet| ?>
10
+ <link rel="stylesheet" href="#{stylesheet.url}" type="text/css" />
11
+ <?r end ?>
9
12
 
10
13
  <script type="importmap">
11
14
  {
12
15
  "imports": {
16
+ "@socketry/presently": "/_components/@socketry/presently/Presently.js",
13
17
  "@socketry/syntax": "/_components/@socketry/syntax/Syntax.js"
14
18
  }
15
19
  }
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "live"
7
+
8
+ module Presently
9
+ # The home view which links to each presentation interface.
10
+ class HomeView < Live::View
11
+ INTERFACES = [
12
+ {
13
+ href: "/display",
14
+ title: "Audience Display",
15
+ description: "Show the synchronized presentation full-screen for the audience.",
16
+ },
17
+ {
18
+ href: "/presenter",
19
+ title: "Presenter Console",
20
+ description: "Control the presentation with speaker notes, timing, and slide previews.",
21
+ },
22
+ {
23
+ href: "/record",
24
+ title: "Narration Recorder",
25
+ description: "Record, review, and save narration for each slide.",
26
+ },
27
+ {
28
+ href: "/playback",
29
+ title: "Narrated Playback",
30
+ description: "Review the complete presentation with its recorded narration.",
31
+ },
32
+ ].map(&:freeze).freeze
33
+
34
+ # Initialize the home view.
35
+ # @parameter id [String] The unique element identifier.
36
+ # @parameter data [Hash] The element data attributes.
37
+ # @parameter controller [PresentationController] The shared presentation controller.
38
+ def initialize(id, data, controller:)
39
+ super(id, data)
40
+ end
41
+
42
+ # Render links to the available presentation interfaces.
43
+ # @parameter builder [XRB::Builder] The HTML builder.
44
+ def render(builder)
45
+ builder.tag(:main, class: "home") do
46
+ builder.tag(:div, class: "home-heading") do
47
+ builder.tag(:p, class: "home-kicker"){builder.text("Presently")}
48
+ builder.tag(:h1){builder.text("Presentation interfaces")}
49
+ builder.tag(:p, class: "home-introduction") do
50
+ builder.text("Choose an interface to present, control, record, or review your presentation.")
51
+ end
52
+ end
53
+
54
+ builder.tag(:nav, class: "home-interfaces", "aria-label": "Presentation interfaces") do
55
+ INTERFACES.each do |interface|
56
+ builder.tag(:a, class: "home-interface", href: interface[:href]) do
57
+ builder.tag(:strong){builder.text(interface[:title])}
58
+ builder.tag(:span){builder.text(interface[:description])}
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -3,36 +3,43 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
- require "xrb/template"
6
+ require "lively/page"
7
7
 
8
8
  module Presently
9
9
  # The HTML page shell for a Presently view.
10
10
  #
11
- # Renders the initial HTML page with the import map, stylesheets, and the
12
- # embedded Live view component. Uses a custom XRB template that includes
13
- # Presently's assets (syntax highlighting, etc.).
14
- class Page
15
- # The compiled XRB template for the page shell.
16
- TEMPLATE = XRB::Template.load_file(File.expand_path("page.xrb", __dir__))
11
+ # Configures Lively's generic page with Presently's assets and embedded Live
12
+ # view component.
13
+ class Page < Lively::Page
14
+ ICON = "/_static/icon.png"
15
+ STYLESHEETS = [
16
+ {href: "/_static/site.css", media: "screen"}.freeze,
17
+ {href: "/_static/index.css", media: "screen"}.freeze,
18
+ {href: "/_static/custom.css", media: "screen"}.freeze,
19
+ {href: "/_components/@socketry/syntax/themes/base/syntax.css", media: "screen"}.freeze,
20
+ ].freeze
21
+ IMPORTS = {
22
+ "live" => "/_components/@socketry/live/Live.js",
23
+ "live-audio" => "/_components/@socketry/live-audio/Live/Audio.js",
24
+ "morphdom" => "/_components/morphdom/morphdom-esm.js",
25
+ "@socketry/presently" => "/_components/@socketry/presently/Presently.js",
26
+ "@socketry/syntax" => "/_components/@socketry/syntax/Syntax.js",
27
+ }.freeze
28
+ MODULES = ["/application.js"].freeze
17
29
 
18
30
  # Initialize a new page.
19
31
  # @parameter title [String] The page title.
20
32
  # @parameter body [Live::View | Nil] The Live view to embed in the page.
21
- def initialize(title: "Presently", body: nil)
22
- @title = title
23
- @body = body
24
- end
25
-
26
- # @attribute [String] The page title.
27
- attr :title
28
-
29
- # @attribute [Live::View | Nil] The Live view to embed.
30
- attr :body
31
-
32
- # Render the page to an HTML string.
33
- # @returns [String] The rendered HTML.
34
- def call
35
- TEMPLATE.to_string(self)
33
+ # @parameter stylesheets [Array(String | Hash)] Presentation-specific stylesheets.
34
+ def initialize(title: "Presently", body: nil, stylesheets: [])
35
+ super(
36
+ title: title,
37
+ body: body,
38
+ icon: ICON,
39
+ stylesheets: STYLESHEETS + stylesheets,
40
+ imports: IMPORTS,
41
+ modules: MODULES,
42
+ )
36
43
  end
37
44
  end
38
45
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "xrb/template"
7
+
8
+ require_relative "slide_renderer"
9
+
10
+ module Presently
11
+ # Renders an isolated, narrated presentation for review and video capture.
12
+ #
13
+ # Unlike the live display, playback owns its current slide entirely in the
14
+ # browser and does not update the shared presentation controller.
15
+ class Playback
16
+ TEMPLATE = XRB::Template.load_file(File.expand_path("playback.xrb", __dir__))
17
+
18
+ # Extract playback options from decoded query parameters.
19
+ # @parameter parameters [Hash] The decoded query parameters.
20
+ # @returns [Hash] Options suitable for passing to {.new}.
21
+ def self.options_from_parameters(parameters)
22
+ {
23
+ autoplay: parameters["autoplay"] == "true",
24
+ controls: parameters["controls"] != "false",
25
+ }
26
+ end
27
+
28
+ # @parameter presentation [Presentation] The presentation to play.
29
+ # @parameter recording_urls [Array(String | Nil)] Narration URL for each slide.
30
+ # @parameter autoplay [Boolean] Whether playback should begin when ready.
31
+ # @parameter controls [Boolean] Whether playback controls should be visible.
32
+ def initialize(presentation:, recording_urls:, autoplay: false, controls: true)
33
+ @presentation = presentation
34
+ @recording_urls = recording_urls
35
+ @autoplay = autoplay
36
+ @controls = controls
37
+ @renderer = SlideRenderer.new(templates: presentation.templates)
38
+ end
39
+
40
+ attr :autoplay
41
+ attr :controls
42
+
43
+ # @returns [Array(Stylesheet)] The ordered presentation stylesheets.
44
+ def stylesheets
45
+ @presentation.stylesheets
46
+ end
47
+
48
+ # @returns [Array(Slide)] The slides in playback order.
49
+ def slides
50
+ @presentation.slides
51
+ end
52
+
53
+ # @returns [String | Nil] The narration URL for a slide index.
54
+ def recording_url(index)
55
+ @recording_urls[index]
56
+ end
57
+
58
+ # Render one slide as HTML.
59
+ # @parameter slide [Slide] The slide to render.
60
+ # @returns [XRB::MarkupString]
61
+ def render_slide(slide)
62
+ @renderer.render_to_html(slide)
63
+ end
64
+
65
+ # Render the complete playback page.
66
+ # @returns [String]
67
+ def call
68
+ TEMPLATE.to_string(self)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,59 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Presently Playback</title>
5
+
6
+ <meta charset="UTF-8" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
8
+
9
+ <link rel="icon" type="image/png" href="/_static/icon.png" />
10
+ <link rel="stylesheet" href="/_static/site.css" type="text/css" media="screen" />
11
+ <link rel="stylesheet" href="/_static/index.css" type="text/css" media="screen" />
12
+ <link rel="stylesheet" href="/_static/custom.css" type="text/css" media="screen" />
13
+ <link rel="stylesheet" href="/_components/@socketry/syntax/themes/base/syntax.css" type="text/css" media="screen" />
14
+ <?r self.stylesheets.each do |stylesheet| ?>
15
+ <link rel="stylesheet" href="#{stylesheet.url}" type="text/css" media="screen" />
16
+ <?r end ?>
17
+
18
+ <script type="importmap">
19
+ {
20
+ "imports": {
21
+ "@socketry/presently": "/_components/@socketry/presently/Presently.js",
22
+ "@socketry/syntax": "/_components/@socketry/syntax/Syntax.js"
23
+ }
24
+ }
25
+ </script>
26
+
27
+ <script type="module" src="/playback.js"></script>
28
+ </head>
29
+
30
+ <body class="playback" data-autoplay="#{self.autoplay}" data-controls="#{self.controls}">
31
+ <main class="playback-stage">
32
+ <?r self.slides.each_with_index do |slide, index| ?>
33
+ <div class="playback-frame" data-index="#{index}" data-transition="#{slide.transition}" data-duration="#{slide.duration}"<?r if index != 0 ?> hidden<?r end ?>>
34
+ #{self.render_slide(slide)}
35
+ </div>
36
+ <?r end ?>
37
+ </main>
38
+
39
+ <div class="playback-audio" hidden>
40
+ <?r self.slides.each_with_index do |slide, index| ?>
41
+ <?r if recording_url = self.recording_url(index) ?>
42
+ <audio data-index="#{index}" preload="auto" src="#{recording_url}"></audio>
43
+ <?r end ?>
44
+ <?r end ?>
45
+ </div>
46
+
47
+ <div class="playback-start-screen">
48
+ <button class="playback-start" type="button" disabled>▶ Start presentation</button>
49
+ <p class="playback-status">Preparing presentation…</p>
50
+ </div>
51
+
52
+ <nav class="playback-controls" aria-label="Playback controls">
53
+ <button class="playback-previous" type="button" title="Previous slide">◀</button>
54
+ <button class="playback-toggle" type="button" title="Play or pause">▶</button>
55
+ <button class="playback-next" type="button" title="Next slide">▶|</button>
56
+ <span class="playback-counter">1 / #{self.slides.length}</span>
57
+ </nav>
58
+ </body>
59
+ </html>
@@ -4,6 +4,7 @@
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
6
  require_relative "slide"
7
+ require_relative "stylesheet"
7
8
  require_relative "templates"
8
9
 
9
10
  module Presently
@@ -28,6 +29,7 @@ module Presently
28
29
  @root = File.expand_path(root)
29
30
  @templates = templates
30
31
  @slides = load_slides
32
+ @stylesheets = Stylesheet.discover(@root, @slides)
31
33
  end
32
34
 
33
35
  # @attribute [String] The absolute root directory containing slide files.
@@ -36,6 +38,9 @@ module Presently
36
38
  # @attribute [Array(Slide)] The ordered list of slides.
37
39
  attr :slides
38
40
 
41
+ # @attribute [Array(Stylesheet)] The ordered presentation stylesheets.
42
+ attr :stylesheets
43
+
39
44
  # @attribute [Templates] The template resolver.
40
45
  attr :templates
41
46
 
@@ -3,7 +3,7 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
- require "live"
6
+ require_relative "slide_view"
7
7
  require_relative "slide_renderer"
8
8
  require_relative "editor"
9
9
 
@@ -12,16 +12,16 @@ module Presently
12
12
  #
13
13
  # Shows the current slide, next slide preview, presenter notes, timing controls,
14
14
  # and pacing indicators. Updates the timing display every second via a background task.
15
- class PresenterView < Live::View
15
+ class PresenterView < SlideView
16
16
  # Initialize a new presenter view.
17
17
  # @parameter id [String] The unique element identifier.
18
18
  # @parameter data [Hash] The element data attributes.
19
- # @parameter controller [PresentationController | Nil] The shared presentation controller.
20
- def initialize(id = Live::Element.unique_id, data = {}, controller: nil)
19
+ # @parameter controller [PresentationController] The shared presentation controller.
20
+ def initialize(id, data, controller:)
21
21
  super(id, data)
22
22
  @controller = controller
23
23
  @clock_task = nil
24
- @preview_renderer = SlideRenderer.new(css_class: "slide preview-slide", templates: controller&.templates)
24
+ @preview_renderer = SlideRenderer.new(css_class: "slide preview-slide", templates: controller.templates)
25
25
  end
26
26
 
27
27
  # Bind this view to a page and start the timing update loop.
@@ -48,7 +48,7 @@ module Presently
48
48
 
49
49
  # Called by the controller when the slide changes.
50
50
  def slide_changed!
51
- self.update!
51
+ self.render_slide!
52
52
  end
53
53
 
54
54
  # Push an update to just the timing section.
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "editor"
7
+ require_relative "slide_view"
8
+ require_relative "slide_renderer"
9
+
10
+ module Presently
11
+ # A dedicated interface for recording one narration track per slide.
12
+ #
13
+ # Recording is intentionally separate from {PresenterView}: presenting is a
14
+ # live performance interface, while recording is an authoring workflow with
15
+ # retakes, playback, and explicit saving.
16
+ class RecordingView < SlideView
17
+ # Initialize a recording view.
18
+ # @parameter id [String] The unique element identifier.
19
+ # @parameter data [Hash] The element data attributes.
20
+ # @parameter controller [PresentationController] The shared presentation controller.
21
+ def initialize(id, data, controller:)
22
+ super(id, data)
23
+ @controller = controller
24
+ @slide_renderer = SlideRenderer.new(css_class: "slide recording-slide", templates: controller.templates)
25
+ end
26
+
27
+ # Bind this view to a page and register for slide changes.
28
+ # @parameter page [Live::Page] The page this view is bound to.
29
+ def bind(page)
30
+ super
31
+ @controller.add_listener(self)
32
+ end
33
+
34
+ # Close this view and unregister it from the controller.
35
+ def close
36
+ @controller.remove_listener(self)
37
+ super
38
+ end
39
+
40
+ # Update the recording interface when the current slide changes.
41
+ def slide_changed!
42
+ self.render_slide!
43
+ end
44
+
45
+ # Handle navigation events from the recording interface.
46
+ # @parameter event [Hash] The forwarded browser event.
47
+ def handle(event)
48
+ case event.dig(:detail, :action)
49
+ when "next"
50
+ @controller.advance!
51
+ when "previous"
52
+ @controller.retreat!
53
+ when "reload"
54
+ @controller.reload!
55
+ when "jump"
56
+ if index = event.dig(:detail, :index)
57
+ @controller.go_to(index.to_i)
58
+ end
59
+ end
60
+ end
61
+
62
+ # Generate an editor URL for the given file path.
63
+ # @parameter path [String] The file path.
64
+ # @parameter line [Integer] The line number.
65
+ # @returns [String | Nil]
66
+ def editor_url_for(path, line = 1)
67
+ Editor.url_for(path, line)
68
+ end
69
+
70
+ # Render the recording interface.
71
+ # @parameter builder [XRB::Builder] The HTML builder.
72
+ def render(builder)
73
+ slide = @controller.current_slide
74
+ return unless slide
75
+
76
+ index = @controller.current_index
77
+ recording_url = "/recordings?index=#{index}"
78
+
79
+ builder.tag(:div, class: "recorder") do
80
+ render_navigation(builder, slide)
81
+
82
+ builder.tag(:div, class: "recording-workspace") do
83
+ builder.tag(:div, class: "recording-preview") do
84
+ @slide_renderer.render(builder, slide)
85
+ end
86
+
87
+ builder.tag(:aside, class: "recording-sidebar") do
88
+ builder.tag(:section, class: "recording-panel") do
89
+ builder.tag(:h2){builder.text("Narration")}
90
+ builder.tag(:p){builder.text("Record, review, and save one audio track for this slide.")}
91
+
92
+ builder.tag("presently-recorder",
93
+ id: "presently-recorder-#{index}",
94
+ "data-recording-url": recording_url
95
+ ) do
96
+ builder.tag(:div, class: "recording-actions") do
97
+ builder.tag(:button, class: "recording-toggle", type: "button"){builder.text("● Record")}
98
+ builder.tag(:button, class: "recording-save", type: "button", disabled: true){builder.text("Save")}
99
+ builder.tag(:span, class: "recording-indicator", "aria-hidden": "true"){}
100
+ builder.tag(:span, class: "recording-time"){builder.text("0:00")}
101
+ end
102
+
103
+ builder.tag(:audio, class: "recording-playback", controls: true, preload: "metadata", hidden: true){}
104
+ builder.tag(:p, class: "recording-status", role: "status"){builder.text("Checking for an existing recording…")}
105
+ end
106
+ end
107
+
108
+ builder.tag(:section, class: "recording-notes") do
109
+ builder.tag(:h2){builder.text("Notes")}
110
+ if notes = slide.notes
111
+ builder.raw(notes.to_html)
112
+ else
113
+ builder.tag(:p, class: "no-notes"){builder.text("No presenter notes for this slide.")}
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ # Render navigation controls for the recording interface.
124
+ # @parameter builder [XRB::Builder] The HTML builder.
125
+ # @parameter slide [Slide] The current slide.
126
+ def render_navigation(builder, slide)
127
+ builder.tag(:div, class: "controls recording-navigation") do
128
+ builder.tag(:button, onClick: forward_event(action: "previous")){builder.text("← Previous")}
129
+
130
+ builder.tag(:span, class: "slide-info") do
131
+ builder.text("Slide #{@controller.current_index + 1} of #{@controller.slide_count} · ")
132
+ builder.tag(:code, class: "slide-path"){builder.text(slide.path)}
133
+
134
+ if editor_url = editor_url_for(slide.source_path)
135
+ builder.tag(:a, href: editor_url, class: "edit-link"){builder.text("✎")}
136
+ end
137
+ end
138
+
139
+ builder.tag(:button, onClick: forward_event(action: "next")){builder.text("Next →")}
140
+
141
+ markers = @controller.slides.each_with_index.filter_map do |candidate, index|
142
+ [index, candidate.marker] if candidate.marker
143
+ end
144
+
145
+ unless markers.empty?
146
+ builder.tag(:select, class: "jump-to", "data-live-id": @id) do
147
+ builder.tag(:option, value: "", disabled: true, selected: true){builder.text("Jump to…")}
148
+ markers.each do |index, label|
149
+ builder.tag(:option, value: index){builder.text(label)}
150
+ end
151
+ end
152
+ end
153
+
154
+ builder.tag(:button, onClick: forward_event(action: "reload"), class: "reload"){builder.text("↻ Reload")}
155
+ end
156
+ end
157
+ end
158
+ end