Skip to content

Commit

Permalink
feat: use UDP to publish/listen for speaker notes
Browse files Browse the repository at this point in the history
  • Loading branch information
mfontanini committed Jan 22, 2025
1 parent 954f112 commit e2d8313
Show file tree
Hide file tree
Showing 10 changed files with 213 additions and 543 deletions.
418 changes: 19 additions & 399 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
serde_json = "1.0"
serde_with = "3.6"
socket2 = "0.5.8"
strum = { version = "0.26", features = ["derive"] }
tempfile = "3.10"
tl = "0.7"
thiserror = "2"
unicode-width = "0.2"
os_pipe = "1.1.5"
libc = "0.2.155"
iceoryx2 = "0.5.0"

[dependencies.syntect]
version = "5.2"
Expand Down
24 changes: 24 additions & 0 deletions config-file-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"snippet": {
"$ref": "#/definitions/SnippetConfig"
},
"speaker_notes": {
"$ref": "#/definitions/SpeakerNotesConfig"
},
"typst": {
"$ref": "#/definitions/TypstConfig"
}
Expand Down Expand Up @@ -496,6 +499,27 @@
},
"additionalProperties": false
},
"SpeakerNotesConfig": {
"type": "object",
"properties": {
"always_publish": {
"description": "Whether to always publish speaker notes.",
"default": false,
"type": "boolean"
},
"listen_address": {
"description": "The address in which to listen for speaker note events.",
"default": "127.255.255.255:59418",
"type": "string"
},
"publish_address": {
"description": "The address in which to publish speaker notes events.",
"default": "127.255.255.255:59418",
"type": "string"
}
},
"additionalProperties": false
},
"TypstConfig": {
"type": "object",
"properties": {
Expand Down
9 changes: 5 additions & 4 deletions examples/speaker-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,17 @@ Run the following two commands in separate terminals.

<!-- speaker_note: This is a speaker note from slide 2. -->

The `--speaker-notes-mode=publisher` argument will render your actual presentation as normal, without speaker notes:
The `--publish-speaker-notes` argument will render your actual presentation as normal, without speaker notes:

```
presenterm --speaker-notes-mode=publisher examples/speaker-notes.md
presenterm --publish-speaker-notes examples/speaker-notes.md
```

The `--speaker-notes-mode=receiver` argument will render only the speaker notes for the current slide being shown in the actual presentation:
The `--listen-speaker-notes` argument will render only the speaker notes for the current slide being shown in the actual
presentation:

```
presenterm --speaker-notes-mode=receiver examples/speaker-notes.md
presenterm --listen-speaker-notes examples/speaker-notes.md
```

<!-- speaker_note: Demonstrate changing slides in the actual presentation. -->
Expand Down
19 changes: 9 additions & 10 deletions src/commands/listener.rs
Original file line number Diff line number Diff line change
@@ -1,38 +1,37 @@
use super::{
SpeakerNotesCommand,
keyboard::{CommandKeyBindings, KeyBindingsValidationError, KeyboardListener},
speaker_notes::{SpeakerNotesEvent, SpeakerNotesEventListener},
};
use crate::{config::KeyBindingsConfig, presenter::PresentationError};
use iceoryx2::{port::subscriber::Subscriber, service::ipc::Service};
use serde::Deserialize;
use std::time::Duration;
use strum::EnumDiscriminants;

/// A command listener that allows polling all command sources in a single place.
pub struct CommandListener {
keyboard: KeyboardListener,
speaker_notes_event_receiver: Option<Subscriber<Service, SpeakerNotesCommand, ()>>,
speaker_notes_event_listener: Option<SpeakerNotesEventListener>,
}

impl CommandListener {
/// Create a new command source over the given presentation path.
pub fn new(
config: KeyBindingsConfig,
speaker_notes_event_receiver: Option<Subscriber<Service, SpeakerNotesCommand, ()>>,
speaker_notes_event_listener: Option<SpeakerNotesEventListener>,
) -> Result<Self, KeyBindingsValidationError> {
let bindings = CommandKeyBindings::try_from(config)?;
Ok(Self { keyboard: KeyboardListener::new(bindings), speaker_notes_event_receiver })
Ok(Self { keyboard: KeyboardListener::new(bindings), speaker_notes_event_listener })
}

/// Try to get the next command.
///
/// This attempts to get a command and returns `Ok(None)` on timeout.
pub(crate) fn try_next_command(&mut self) -> Result<Option<Command>, PresentationError> {
if let Some(receiver) = self.speaker_notes_event_receiver.as_mut() {
if let Some(msg) = receiver.receive()? {
let command = match msg.payload() {
SpeakerNotesCommand::GoToSlide(idx) => Command::GoToSlide(*idx),
SpeakerNotesCommand::Exit => Command::Exit,
if let Some(receiver) = &self.speaker_notes_event_listener {
if let Some(msg) = receiver.try_recv()? {
let command = match msg {
SpeakerNotesEvent::GoToSlide { slide } => Command::GoToSlide(slide),
SpeakerNotesEvent::Exit => Command::Exit,
};
return Ok(Some(command));
}
Expand Down
8 changes: 1 addition & 7 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
pub(crate) mod keyboard;
pub(crate) mod listener;

#[derive(Debug)]
#[repr(C)]
pub enum SpeakerNotesCommand {
GoToSlide(u32),
Exit,
}
pub(crate) mod speaker_notes;
75 changes: 75 additions & 0 deletions src/commands/speaker_notes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use serde::{Deserialize, Serialize};
use socket2::{Domain, Protocol, Socket, Type};
use std::{
io,
net::{SocketAddr, UdpSocket},
path::PathBuf,
};

pub struct SpeakerNotesEventPublisher {
socket: UdpSocket,
presentation_path: PathBuf,
}

impl SpeakerNotesEventPublisher {
pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result<Self> {
let socket = UdpSocket::bind("127.0.0.1:0")?;
socket.set_broadcast(true)?;
socket.connect(address)?;
Ok(Self { socket, presentation_path })
}

pub(crate) fn send(&self, event: SpeakerNotesEvent) -> io::Result<()> {
// Wrap this event in an envelope that contains the presentation path so listeners can
// ignore unrelated events.
let envelope = SpeakerNotesEventEnvelope { event, presentation_path: self.presentation_path.clone() };
let data = serde_json::to_string(&envelope).expect("serialization failed");
self.socket.send(data.as_bytes())?;
Ok(())
}
}

pub struct SpeakerNotesEventListener {
socket: UdpSocket,
presentation_path: PathBuf,
}

impl SpeakerNotesEventListener {
pub fn new(address: SocketAddr, presentation_path: PathBuf) -> io::Result<Self> {
let s = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?;
// Use SO_REUSEADDR so we can have multiple listeners on the same port.
s.set_reuse_address(true)?;
// Don't block so we can listen to the keyboard and this socket at the same time.
s.set_nonblocking(true)?;
s.bind(&address.into())?;
Ok(Self { socket: s.into(), presentation_path })
}

pub(crate) fn try_recv(&self) -> io::Result<Option<SpeakerNotesEvent>> {
let mut buffer = [0; 1024];
let bytes_read = match self.socket.recv(&mut buffer) {
Ok(bytes_read) => bytes_read,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => return Ok(None),
Err(e) => return Err(e),
};
// Ignore garbage. Odds are this is someone else sending garbage rather than presenterm
// itself.
let Ok(envelope) = serde_json::from_slice::<SpeakerNotesEventEnvelope>(&buffer[0..bytes_read]) else {
return Ok(None);
};
if envelope.presentation_path == self.presentation_path { Ok(Some(envelope.event)) } else { Ok(None) }
}
}

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "command")]
pub(crate) enum SpeakerNotesEvent {
GoToSlide { slide: u32 },
Exit,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
struct SpeakerNotesEventEnvelope {
presentation_path: PathBuf,
event: SpeakerNotesEvent,
}
34 changes: 34 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use serde::Deserialize;
use std::{
collections::{BTreeMap, HashMap},
fs, io,
net::{IpAddr, Ipv4Addr, SocketAddr},
path::Path,
};

Expand All @@ -35,6 +36,9 @@ pub struct Config {

#[serde(default)]
pub snippet: SnippetConfig,

#[serde(default)]
pub speaker_notes: SpeakerNotesConfig,
}

impl Config {
Expand Down Expand Up @@ -385,6 +389,32 @@ impl Default for KeyBindingsConfig {
}
}

#[derive(Clone, Debug, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SpeakerNotesConfig {
/// The address in which to listen for speaker note events.
#[serde(default = "default_speaker_notes_address")]
pub listen_address: SocketAddr,

/// The address in which to publish speaker notes events.
#[serde(default = "default_speaker_notes_address")]
pub publish_address: SocketAddr,

/// Whether to always publish speaker notes.
#[serde(default)]
pub always_publish: bool,
}

impl Default for SpeakerNotesConfig {
fn default() -> Self {
Self {
listen_address: default_speaker_notes_address(),
publish_address: default_speaker_notes_address(),
always_publish: false,
}
}
}

fn make_keybindings<const N: usize>(raw_bindings: [&str; N]) -> Vec<KeyBinding> {
let mut bindings = Vec::new();
for binding in raw_bindings {
Expand Down Expand Up @@ -449,6 +479,10 @@ fn default_suspend_bindings() -> Vec<KeyBinding> {
make_keybindings(["<c-z>"])
}

fn default_speaker_notes_address() -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 255, 255, 255)), 59418)
}

#[cfg(test)]
mod test {
use super::*;
Expand Down
Loading

0 comments on commit e2d8313

Please sign in to comment.