1use std::sync::{Arc, RwLock};
8
9use dais_core::bus::CommandSender;
10use dais_core::config::Config;
11use dais_core::keybindings::KeybindingMap;
12use dais_core::state::PresentationState;
13use dais_document::cache::PageCache;
14use dais_document::page::RenderSize;
15use dais_document::render_pipeline::{FALLBACK_RENDER_SIZE, RenderPipeline};
16use dais_document::source::DocumentSource;
17use dais_engine::engine::PresentationEngine;
18use dais_remote::RemoteStatusHandle;
19
20use crate::audience::AudienceWindow;
21use crate::display_mode::{self, AudienceReassignmentPrompt, DisplayMode, SingleMonitorView};
22use crate::input::InputHandler;
23use crate::presenter::PresenterConsole;
24use crate::presenter::hud::HudOverlay;
25use crate::widgets::ToastManager;
26
27pub struct DaisApp {
29 engine: PresentationEngine,
30 shared_state: Arc<RwLock<PresentationState>>,
31 cache: PageCache,
32 pipeline: RenderPipeline,
33 presenter: PresenterConsole,
34 hud: HudOverlay,
35 audience: AudienceWindow,
36 sender: CommandSender,
37 display_mode: DisplayMode,
38 single_monitor_view: SingleMonitorView,
39 audience_reassignment: Option<AudienceReassignmentPrompt>,
40 toast_manager: ToastManager,
41 remote: Option<RemoteUiInfo>,
42 presenter_window_size: egui::Vec2,
43 applied_presenter_monitor_id: Option<String>,
44}
45
46const MAX_ZOOM_RENDER_DIMENSION: u32 = 4320;
47
48#[derive(Clone)]
49pub struct RemoteUiInfo {
50 pub urls: Vec<String>,
51 pub token: String,
52 pub requires_token: bool,
53 pub status: RemoteStatusHandle,
54}
55
56impl DaisApp {
57 pub fn new(
59 engine: PresentationEngine,
60 shared_state: Arc<RwLock<PresentationState>>,
61 doc: Arc<dyn DocumentSource>,
62 sender: CommandSender,
63 config: &Config,
64 display_mode: DisplayMode,
65 ) -> Self {
66 let keybindings = KeybindingMap::from_full_config(config);
67 let input = InputHandler::new(sender.clone(), keybindings);
68 let presenter = PresenterConsole::new(input);
69 let audience = AudienceWindow::new();
70 let cache = PageCache::new(64);
71 let pipeline = RenderPipeline::new(doc, 2);
72 let presenter_window_size = egui::vec2(1400.0, 850.0);
73
74 Self {
75 engine,
76 shared_state,
77 cache,
78 pipeline,
79 presenter,
80 hud: HudOverlay::new(),
81 audience,
82 sender,
83 display_mode,
84 single_monitor_view: SingleMonitorView::from_config(
85 &config.display.single_monitor_view,
86 ),
87 audience_reassignment: None,
88 toast_manager: ToastManager::new(),
89 remote: None,
90 presenter_window_size,
91 applied_presenter_monitor_id: None,
92 }
93 }
94
95 pub fn toast_manager_mut(&mut self) -> &mut ToastManager {
96 &mut self.toast_manager
97 }
98
99 pub fn set_audience_reassignment(&mut self, prompt: Option<AudienceReassignmentPrompt>) {
100 self.audience_reassignment = prompt;
101 }
102
103 pub fn set_remote_info(&mut self, remote: Option<RemoteUiInfo>) {
104 self.remote = remote;
105 }
106}
107
108impl eframe::App for DaisApp {
109 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
110 let should_quit = self.engine.tick();
112 if should_quit {
113 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
114 return;
115 }
116
117 self.pipeline.poll_results(&mut self.cache);
119
120 let state = self.shared_state.read().map_or_else(
122 |e| {
123 tracing::error!("Failed to read state: {e}");
124 PresentationState::new(0, Vec::new())
125 },
126 |s| s.clone(),
127 );
128
129 let presenter_size = FALLBACK_RENDER_SIZE;
131 let effective_display_mode =
132 display_mode::effective_display_mode(&self.display_mode, state.displays_swapped);
133 self.apply_presenter_viewport(ctx, &effective_display_mode);
134 let base_audience_size = display_mode::audience_render_size(&effective_display_mode);
135 let audience_size = effective_audience_render_size(&state, base_audience_size);
136 self.pipeline.prefetch_neighborhood(
137 state.current_page,
138 state.total_pages,
139 presenter_size,
140 &mut self.cache,
141 );
142 self.pipeline.ensure_rendered(state.audience_page(), base_audience_size, &mut self.cache);
144 self.pipeline.ensure_rendered(state.audience_page(), audience_size, &mut self.cache);
145
146 if state.overview_visible {
148 for group in &state.slide_groups {
149 if let Some(&first_page) = group.pages.first() {
150 self.pipeline.ensure_rendered(first_page, presenter_size, &mut self.cache);
151 }
152 }
153 }
154
155 if state.timer.running {
157 ctx.request_repaint_after(std::time::Duration::from_millis(100));
158 } else {
159 ctx.request_repaint_after(std::time::Duration::from_millis(250));
162 }
163
164 if matches!(self.display_mode, DisplayMode::Single) {
167 let render_size = effective_audience_render_size(&state, FALLBACK_RENDER_SIZE);
168 let active_view = if state.presentation_mode {
169 match self.single_monitor_view {
170 SingleMonitorView::Hud => SingleMonitorView::Split,
171 SingleMonitorView::Split => SingleMonitorView::Hud,
172 }
173 } else {
174 self.single_monitor_view
175 };
176 match active_view {
177 SingleMonitorView::Hud => {
178 let input = self.presenter.input_mut();
179 self.hud.show(ctx, &state, &mut self.cache, &self.sender, input, render_size);
180 }
181 SingleMonitorView::Split => {
182 self.presenter.show_single_monitor_split(
183 ctx,
184 &state,
185 &mut self.cache,
186 &self.sender,
187 render_size,
188 self.remote.as_ref(),
189 );
190 }
191 }
192 self.show_audience_reassignment_prompt(ctx);
193 self.toast_manager.show(ctx);
194 return;
195 }
196
197 let is_runtime_screen_share = state.screen_share_mode;
199
200 self.presenter.show(ctx, &state, &mut self.cache, &self.sender, self.remote.as_ref());
202
203 let viewport_builder = if is_runtime_screen_share {
205 display_mode::with_app_icon(egui::ViewportBuilder::default())
206 .with_title("Dais — Audience")
207 .with_inner_size(egui::vec2(1280.0, 720.0))
208 .with_fullscreen(false)
209 .with_resizable(true)
210 } else {
211 display_mode::audience_viewport_builder(&effective_display_mode)
212 };
213
214 let shared = self.shared_state.clone();
215 let audience = &mut self.audience;
216 let cache = &mut self.cache;
217 let shared_ref = &shared;
218
219 ctx.show_viewport_immediate(
220 egui::ViewportId::from_hash_of("audience"),
221 viewport_builder,
222 |ctx, _class| {
223 audience.show(ctx, shared_ref, cache, audience_size);
224 },
225 );
226
227 self.show_audience_reassignment_prompt(ctx);
228 self.toast_manager.show(ctx);
229 }
230}
231
232impl DaisApp {
233 fn apply_presenter_viewport(&mut self, ctx: &egui::Context, mode: &DisplayMode) {
234 let DisplayMode::Dual { presenter_monitor, .. } = mode else {
235 self.applied_presenter_monitor_id = None;
236 return;
237 };
238
239 if self.applied_presenter_monitor_id.as_deref() == Some(presenter_monitor.id.as_str()) {
240 return;
241 }
242
243 let Some(placement) = display_mode::presenter_viewport_placement(
244 presenter_monitor,
245 self.presenter_window_size,
246 ) else {
247 return;
248 };
249 ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
250 ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(false));
251 ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(placement.inner_size));
252 ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(placement.position));
253 ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
254 self.applied_presenter_monitor_id = Some(presenter_monitor.id.clone());
255 }
256
257 fn current_presenter_monitor(&self) -> Option<dais_core::monitor::MonitorInfo> {
258 match &self.display_mode {
259 DisplayMode::Dual { presenter_monitor, .. } => Some(presenter_monitor.clone()),
260 DisplayMode::Single | DisplayMode::ScreenShare => None,
261 }
262 }
263
264 fn presenter_for_audience(
265 &self,
266 audience_monitor: &dais_core::monitor::MonitorInfo,
267 available_monitors: &[dais_core::monitor::MonitorInfo],
268 ) -> dais_core::monitor::MonitorInfo {
269 self.current_presenter_monitor()
270 .filter(|presenter| presenter.id != audience_monitor.id)
271 .or_else(|| {
272 available_monitors
273 .iter()
274 .find(|candidate| candidate.id != audience_monitor.id)
275 .cloned()
276 })
277 .unwrap_or_else(|| audience_monitor.clone())
278 }
279
280 fn show_audience_reassignment_prompt(&mut self, ctx: &egui::Context) {
281 let Some(prompt) = self.audience_reassignment.clone() else {
282 return;
283 };
284
285 egui::Window::new("Audience Monitor Changed")
286 .collapsible(false)
287 .resizable(false)
288 .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
289 .show(ctx, |ui| {
290 ui.set_min_width(420.0);
291 ui.label(format!(
292 "Configured audience monitor '{}' is not available.",
293 prompt.missing_selector
294 ));
295
296 if let Some(fallback) = &prompt.attempted_fallback {
297 ui.label(format!(
298 "Dais is currently using '{}' as a fallback for this session.",
299 fallback.name
300 ));
301 } else {
302 ui.label("No audience monitor fallback was available, so Dais switched to single-monitor mode.");
303 }
304
305 ui.separator();
306 ui.label("Choose the audience output for this session:");
307
308 let mut dismiss = false;
309 let fallback_id = prompt.attempted_fallback.as_ref().map(|monitor| monitor.id.as_str());
310 let alternative_monitors = prompt
311 .available_monitors
312 .iter()
313 .filter(|monitor| Some(monitor.id.as_str()) != fallback_id)
314 .cloned()
315 .collect::<Vec<_>>();
316
317 if alternative_monitors.is_empty() {
318 ui.label(
319 egui::RichText::new("No alternate audience monitors were detected.")
320 .small()
321 .color(egui::Color32::GRAY),
322 );
323 }
324
325 for monitor in &alternative_monitors {
326 let primary = if monitor.is_primary { ", primary" } else { "" };
327 let label = format!("Use {} ({}{primary})", monitor.name, monitor.id);
328 if ui.button(label).clicked() {
329 let presenter_monitor =
330 self.presenter_for_audience(monitor, &prompt.available_monitors);
331 self.display_mode = DisplayMode::Dual {
332 presenter_monitor,
333 audience_monitor: monitor.clone(),
334 };
335 self.toast_manager.push(
336 crate::widgets::toast::ToastLevel::Info,
337 format!("Audience moved to '{}'", monitor.name),
338 );
339 dismiss = true;
340 }
341 }
342
343 ui.horizontal(|ui| {
344 if let Some(fallback) = &prompt.attempted_fallback {
345 if ui.button("Keep current fallback").clicked() {
346 let presenter_monitor =
347 self.presenter_for_audience(fallback, &prompt.available_monitors);
348 self.display_mode = DisplayMode::Dual {
349 presenter_monitor,
350 audience_monitor: fallback.clone(),
351 };
352 self.toast_manager.push(
353 crate::widgets::toast::ToastLevel::Info,
354 format!("Keeping fallback audience monitor '{}'", fallback.name),
355 );
356 dismiss = true;
357 }
358
359 if ui.button("Use single-monitor mode").clicked() {
360 self.display_mode = DisplayMode::Single;
361 self.toast_manager.push(
362 crate::widgets::toast::ToastLevel::Info,
363 "Audience reassigned to single-monitor mode",
364 );
365 dismiss = true;
366 }
367 } else if ui.button("Stay in single-monitor mode").clicked() {
368 self.display_mode = DisplayMode::Single;
369 self.toast_manager.push(
370 crate::widgets::toast::ToastLevel::Info,
371 "Keeping single-monitor mode",
372 );
373 dismiss = true;
374 }
375 });
376
377 ui.label(
378 egui::RichText::new(
379 "This updates the current session only. You can make it permanent in `dais.toml` later.",
380 )
381 .small()
382 .color(egui::Color32::GRAY),
383 );
384
385 if dismiss {
386 self.audience_reassignment = None;
387 }
388 });
389 }
390}
391
392#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)]
393fn effective_audience_render_size(state: &PresentationState, base_size: RenderSize) -> RenderSize {
394 let Some(region) = state.zoom_region.as_ref().filter(|_| state.zoom_active) else {
395 return base_size;
396 };
397
398 let multiplier = region.factor.clamp(1.0, 3.0);
402 let width = ((base_size.width as f32) * multiplier).round() as u32;
403 let height = ((base_size.height as f32) * multiplier).round() as u32;
404
405 RenderSize {
406 width: width.clamp(base_size.width, MAX_ZOOM_RENDER_DIMENSION),
407 height: height.clamp(base_size.height, MAX_ZOOM_RENDER_DIMENSION),
408 }
409}