Skip to content

Commit 54b6c8b

Browse files
committed
feat(presentation): add speaker notes mode with sync functionality
- Add `--notes` flag to enable speaker notes mode in `cmd/root.go` - Add `Notes` field to slide properties configuration in `config/style.go` - Add `LoadNoOp()` function to disable logging in speaker notes mode - Create `speaker_notes.go` with dedicated TUI for speaker notes display - Create `sync_server.go` with TCP server/client for slide synchronization - Update main TUI to broadcast slide changes to speaker notes clients - Add automatic reconnection logic when presentation restarts - Clean up sync server resources on application exit
1 parent 648ea85 commit 54b6c8b

File tree

6 files changed

+482
-19
lines changed

6 files changed

+482
-19
lines changed

cmd/root.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,14 @@ var (
2323
static bool
2424
configPath string
2525
logPath string
26+
notes bool
2627
)
2728

2829
func init() {
2930
rootCmd.Flags().BoolVarP(&static, "static", "s", false, "Disable live reload (watch mode is enabled by default)")
3031
rootCmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to config file")
3132
rootCmd.Flags().StringVarP(&logPath, "log", "l", "", "Path to log file (default: ~/.config/kyma/logs/<timestamp>.kyma.log)")
33+
rootCmd.Flags().BoolVarP(&notes, "notes", "n", false, "Run in speaker notes mode")
3234
rootCmd.AddCommand(versionCmd)
3335
}
3436

@@ -48,8 +50,15 @@ var rootCmd = &cobra.Command{
4850
DisableDefaultCmd: true,
4951
},
5052
RunE: func(cmd *cobra.Command, args []string) error {
51-
if err := logger.Load(logPath); err != nil {
52-
return fmt.Errorf("failed to initialize slog: %w", err)
53+
// Use no-op logger for speaker notes mode, regular logger for main presentation
54+
if notes {
55+
if err := logger.LoadNoOp(); err != nil {
56+
return fmt.Errorf("failed to initialize no-op logger: %w", err)
57+
}
58+
} else {
59+
if err := logger.Load(logPath); err != nil {
60+
return fmt.Errorf("failed to initialize slog: %w", err)
61+
}
5362
}
5463

5564
slog.Info("Starting Kyma")
@@ -76,7 +85,16 @@ var rootCmd = &cobra.Command{
7685

7786
slog.Info("Successfully parsed presentation")
7887

79-
p := tea.NewProgram(tui.New(root), tea.WithAltScreen(), tea.WithMouseAllMotion())
88+
if notes {
89+
speakerModel := tui.NewSpeakerNotes(root)
90+
p := tea.NewProgram(speakerModel, tea.WithAltScreen())
91+
if _, err := p.Run(); err != nil {
92+
return err
93+
}
94+
return nil
95+
}
96+
97+
p := tea.NewProgram(tui.New(root, filename), tea.WithAltScreen(), tea.WithMouseAllMotion())
8098

8199
if !static {
82100
slog.Info("Starting file watcher for live reload")

internal/config/style.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type Properties struct {
2626
Title string `yaml:"title"`
2727
Style StyleConfig `yaml:"style"`
2828
Transition transitions.Transition `yaml:"transition"`
29+
Notes string `yaml:"notes"`
2930
}
3031

3132
type SlideStyle struct {
@@ -217,13 +218,15 @@ func (p *Properties) UnmarshalYAML(bytes []byte) error {
217218
Style StyleConfig `yaml:"style"`
218219
Transition string `yaml:"transition"`
219220
Preset string `yaml:"preset"`
221+
Notes string `yaml:"notes"`
220222
}{}
221223

222224
if err := yaml.Unmarshal(bytes, &aux); err != nil {
223225
return err
224226
}
225227

226228
p.Title = aux.Title
229+
p.Notes = aux.Notes
227230

228231
if aux.Preset != "" {
229232
preset, ok := GlobalConfig.Presets[aux.Preset]

internal/logger/logger.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ func Load(logPath string) error {
2727
return initLogger(logFilePath)
2828
}
2929

30+
func LoadNoOp() error {
31+
handler := slog.NewTextHandler(nil, &slog.HandlerOptions{
32+
Level: slog.LevelError + 1,
33+
})
34+
35+
logger := slog.New(handler)
36+
slog.SetDefault(logger)
37+
38+
return nil
39+
}
40+
3041
func initLogger(logPath string) error {
3142
logDir := filepath.Dir(logPath)
3243
if err := os.MkdirAll(logDir, 0755); err != nil {

internal/tui/speaker_notes.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package tui
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"time"
7+
8+
tea "github.com/charmbracelet/bubbletea"
9+
"github.com/charmbracelet/glamour"
10+
"github.com/charmbracelet/lipgloss"
11+
)
12+
13+
// ConnectionStatus represents the state of the sync connection
14+
type ConnectionStatus int
15+
16+
const (
17+
StatusDisconnectedWaiting ConnectionStatus = iota
18+
StatusConnected
19+
StatusDisconnectedClosed
20+
StatusReconnecting
21+
)
22+
23+
func (s ConnectionStatus) String() string {
24+
switch s {
25+
case StatusDisconnectedWaiting:
26+
return "Disconnected - waiting for presentation to start"
27+
case StatusConnected:
28+
return "Connected"
29+
case StatusDisconnectedClosed:
30+
return "Disconnected - presentation closed"
31+
case StatusReconnecting:
32+
return "Reconnecting..."
33+
default:
34+
return "Unknown"
35+
}
36+
}
37+
38+
type SpeakerNotesModel struct {
39+
width int
40+
height int
41+
currentSlide int
42+
slides []*Slide
43+
syncClient *SyncClient
44+
slideChangeChan chan int
45+
connectionStatus ConnectionStatus
46+
reconnecting bool
47+
}
48+
49+
type SlideChangeMsg struct {
50+
SlideNumber int
51+
}
52+
53+
type ConnectionLostMsg struct{}
54+
55+
type ReconnectAttemptMsg struct{}
56+
57+
type ReconnectedMsg struct {
58+
Client *SyncClient
59+
}
60+
61+
func NewSpeakerNotes(rootSlide *Slide) SpeakerNotesModel {
62+
// Create slides array for easier indexing
63+
var slides []*Slide
64+
slide := rootSlide
65+
for slide != nil {
66+
slides = append(slides, slide)
67+
slide = slide.Next
68+
}
69+
70+
// Create sync client to connect to the main presentation
71+
syncClient, err := NewSyncClient()
72+
if err != nil {
73+
slog.Warn("Failed to connect to sync server - run the main presentation first", "error", err)
74+
syncClient = nil
75+
}
76+
77+
// Create buffered channel for slide changes
78+
slideChangeChan := make(chan int, 10)
79+
80+
return SpeakerNotesModel{
81+
currentSlide: 0,
82+
slides: slides,
83+
syncClient: syncClient,
84+
slideChangeChan: slideChangeChan,
85+
connectionStatus: StatusConnected,
86+
reconnecting: false,
87+
}
88+
}
89+
90+
func (m SpeakerNotesModel) Init() tea.Cmd {
91+
if m.syncClient != nil {
92+
// Start listening for slide changes in a goroutine
93+
go m.listenForSlideChangesWithReconnect()
94+
95+
return tea.Batch(
96+
tea.ClearScreen,
97+
m.waitForSlideChange(),
98+
)
99+
}
100+
101+
// No connection initially, start trying to reconnect
102+
return tea.Batch(
103+
tea.ClearScreen,
104+
m.attemptReconnect(),
105+
)
106+
}
107+
108+
func (m SpeakerNotesModel) waitForSlideChange() tea.Cmd {
109+
return func() tea.Msg {
110+
slideNum := <-m.slideChangeChan
111+
return SlideChangeMsg{SlideNumber: slideNum}
112+
}
113+
}
114+
115+
func (m SpeakerNotesModel) listenForSlideChangesWithReconnect() {
116+
if m.syncClient == nil {
117+
return
118+
}
119+
120+
// Listen for slide changes and detect disconnection
121+
m.syncClient.ListenForSlideChanges(m.slideChangeChan)
122+
123+
// If we reach here, the connection was lost
124+
select {
125+
case m.slideChangeChan <- -1:
126+
default:
127+
}
128+
}
129+
130+
func (m SpeakerNotesModel) attemptReconnect() tea.Cmd {
131+
return func() tea.Msg {
132+
syncClient, err := NewSyncClient()
133+
if err != nil {
134+
return ReconnectAttemptMsg{}
135+
}
136+
137+
return ReconnectedMsg{Client: syncClient}
138+
}
139+
}
140+
141+
func (m SpeakerNotesModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
142+
switch msg := msg.(type) {
143+
case tea.WindowSizeMsg:
144+
m.width, m.height = msg.Width, msg.Height
145+
return m, nil
146+
case SlideChangeMsg:
147+
if msg.SlideNumber == -1 {
148+
m.connectionStatus = StatusDisconnectedClosed
149+
if m.syncClient != nil {
150+
m.syncClient.Close()
151+
m.syncClient = nil
152+
}
153+
// Start reconnection attempts
154+
return m, m.attemptReconnect()
155+
}
156+
157+
if msg.SlideNumber >= 0 && msg.SlideNumber < len(m.slides) {
158+
m.currentSlide = msg.SlideNumber
159+
slog.Info("Speaker notes: slide changed", "slide", msg.SlideNumber)
160+
}
161+
// Continue waiting for more slide changes
162+
return m, m.waitForSlideChange()
163+
case ReconnectAttemptMsg:
164+
m.connectionStatus = StatusReconnecting
165+
m.reconnecting = true
166+
// Wait a bit before trying again
167+
return m, tea.Tick(time.Millisecond*200, func(time.Time) tea.Msg {
168+
return m.attemptReconnect()()
169+
})
170+
case ReconnectedMsg:
171+
m.syncClient = msg.Client
172+
m.connectionStatus = StatusConnected
173+
m.reconnecting = false
174+
// Start listening for slide changes again
175+
go m.listenForSlideChangesWithReconnect()
176+
return m, m.waitForSlideChange()
177+
case tea.KeyMsg:
178+
switch msg.String() {
179+
case "q", "esc", "ctrl+c":
180+
return m, tea.Quit
181+
}
182+
}
183+
184+
return m, nil
185+
}
186+
187+
func (m SpeakerNotesModel) View() string {
188+
if len(m.slides) == 0 {
189+
return "No slides available"
190+
}
191+
192+
if m.currentSlide >= len(m.slides) {
193+
m.currentSlide = len(m.slides) - 1
194+
}
195+
196+
slide := m.slides[m.currentSlide]
197+
notes := slide.Properties.Notes
198+
199+
if notes == "" {
200+
notes = "No speaker notes for this slide."
201+
}
202+
203+
headerText := fmt.Sprintf("Speaker Notes - Slide %d/%d (%s)", m.currentSlide+1, len(m.slides), m.connectionStatus)
204+
205+
header := lipgloss.NewStyle().
206+
Bold(true).
207+
Padding(1).
208+
Foreground(lipgloss.Color("#9999CC")).
209+
Render(headerText)
210+
211+
rendered, err := glamour.Render(notes, "dark")
212+
if err != nil {
213+
rendered = notes
214+
}
215+
216+
notesStyle := lipgloss.NewStyle().
217+
Padding(2).
218+
Border(lipgloss.RoundedBorder()).
219+
BorderForeground(lipgloss.Color("8")).
220+
MarginTop(1)
221+
222+
content := notesStyle.Render(rendered)
223+
224+
return lipgloss.JoinVertical(lipgloss.Left, header, content)
225+
}

0 commit comments

Comments
 (0)