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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/bake/presently/export.rb +187 -45
- data/bake/presently/recordings.rb +72 -0
- data/context/animating-slides.md +25 -1
- data/lib/presently/application.rb +157 -30
- data/lib/presently/display_view.rb +7 -7
- data/lib/presently/environment/application.rb +17 -1
- data/lib/presently/export.rb +15 -13
- data/lib/presently/export.xrb +4 -0
- data/lib/presently/home_view.rb +65 -0
- data/lib/presently/page.rb +29 -22
- data/lib/presently/playback.rb +71 -0
- data/lib/presently/playback.xrb +59 -0
- data/lib/presently/presentation.rb +5 -0
- data/lib/presently/presenter_view.rb +6 -6
- data/lib/presently/recording_view.rb +158 -0
- data/lib/presently/recordings/normalizer.rb +183 -0
- data/lib/presently/recordings.rb +100 -0
- data/lib/presently/slide.rb +35 -3
- data/lib/presently/slide_assets.rb +101 -0
- data/lib/presently/slide_renderer.rb +2 -1
- data/lib/presently/slide_view.rb +23 -0
- data/lib/presently/stylesheet.rb +112 -0
- data/lib/presently/version.rb +1 -1
- data/lib/presently.rb +5 -0
- data/public/{code-focus.js → _components/@socketry/presently/Presently/CodeFocus.js} +1 -0
- data/public/{slide-scripts.js → _components/@socketry/presently/Presently/Scripts.js} +1 -1
- data/public/{slide.js → _components/@socketry/presently/Presently/Slide.js} +70 -13
- data/public/_components/@socketry/presently/Presently/SlideRendering.js +109 -0
- data/public/_components/@socketry/presently/Presently.js +4 -0
- data/public/_components/@socketry/syntax/Syntax/CodeElement.js +116 -37
- data/public/_components/@socketry/syntax/Syntax/Language.js +5 -2
- data/public/_components/@socketry/syntax/Syntax/Match.js +8 -2
- data/public/_components/@socketry/syntax/Syntax/Rule.js +21 -5
- data/public/_components/@socketry/syntax/Syntax.js +2 -2
- data/public/_components/@socketry/syntax/package.json +6 -5
- data/public/_components/@socketry/syntax/readme.md +7 -1
- data/public/_components/@socketry/syntax/themes/base/syntax.css +11 -0
- data/public/_components/@socketry/syntax/themes/theming.md +11 -0
- data/public/_static/index.css +381 -0
- data/public/application.js +29 -64
- data/public/export.js +1 -3
- data/public/playback.js +266 -0
- data/public/recorder.js +282 -0
- data/readme.md +38 -0
- data/releases.md +8 -0
- data.tar.gz.sig +0 -0
- metadata +34 -8
- metadata.gz.sig +0 -0
- data/bake/presently/node.rb +0 -47
- 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
|
-
|
|
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 <
|
|
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
|
|
19
|
-
def initialize(id
|
|
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
|
|
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.
|
|
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.
|
|
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
|
data/lib/presently/export.rb
CHANGED
|
@@ -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
|
-
#
|
|
41
|
-
# @parameter
|
|
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.
|
|
44
|
-
return {}
|
|
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: (
|
|
50
|
-
slide_height_px: (
|
|
51
|
-
notes_height_px: (
|
|
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:
|
|
56
|
-
speaker:
|
|
57
|
-
timing:
|
|
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]
|
data/lib/presently/export.xrb
CHANGED
|
@@ -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
|
data/lib/presently/page.rb
CHANGED
|
@@ -3,36 +3,43 @@
|
|
|
3
3
|
# Released under the MIT License.
|
|
4
4
|
# Copyright, 2026, by Samuel Williams.
|
|
5
5
|
|
|
6
|
-
require "
|
|
6
|
+
require "lively/page"
|
|
7
7
|
|
|
8
8
|
module Presently
|
|
9
9
|
# The HTML page shell for a Presently view.
|
|
10
10
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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 <
|
|
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
|
|
20
|
-
def initialize(id
|
|
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
|
|
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.
|
|
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
|