use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use eframe::egui;
use image::RgbImage;
use crate::capture::{CameraOption, CaptureHandle, list_cameras, start_capture};
use crate::snapshot::{SnapshotSettings, save_snapshot};
type SnapResult = Result<PathBuf, String>;
enum State {
Picker {
cameras: Vec<CameraOption>,
error: Option<String>,
},
Previewing {
capture: CaptureHandle,
camera_name: String,
},
}
enum Action {
None,
Start(CameraOption),
BackToPicker(Option<String>),
Quit,
}
pub struct CamshooterApp {
settings: SnapshotSettings,
state: State,
texture: Option<egui::TextureHandle>,
last_frame: Option<RgbImage>,
status: Option<String>,
snap_tx: Sender<SnapResult>,
snap_rx: Receiver<SnapResult>,
snap_in_flight: Arc<AtomicBool>,
}
impl CamshooterApp {
pub fn new(_cc: &eframe::CreationContext<'_>, settings: SnapshotSettings) -> Self {
let (snap_tx, snap_rx) = channel();
let state = match list_cameras() {
Ok(cameras) => State::Picker {
cameras,
error: None,
},
Err(e) => State::Picker {
cameras: Vec::new(),
error: Some(format!("Could not list cameras: {e}")),
},
};
Self {
settings,
state,
texture: None,
last_frame: None,
status: None,
snap_tx,
snap_rx,
snap_in_flight: Arc::new(AtomicBool::new(false)),
}
}
fn apply(&mut self, action: Action, ctx: &egui::Context) {
match action {
Action::None => {}
Action::Start(opt) => {
self.stop_capture(); self.texture = None;
self.last_frame = None;
self.status = None;
let capture = start_capture(opt.index.clone(), ctx.clone());
self.state = State::Previewing {
capture,
camera_name: opt.name,
};
}
Action::BackToPicker(err) => {
self.stop_capture();
self.texture = None;
self.last_frame = None;
let cameras = list_cameras().unwrap_or_default();
self.state = State::Picker {
cameras,
error: err,
};
}
Action::Quit => {
self.stop_capture(); ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
}
fn stop_capture(&mut self) {
if let State::Previewing { capture, .. } = &mut self.state {
capture.stop();
}
}
}
impl eframe::App for CamshooterApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let ctx = ui.ctx().clone();
while let Ok(result) = self.snap_rx.try_recv() {
self.status = Some(match result {
Ok(path) => format!("Saved {}", path.display()),
Err(e) => format!("Snapshot failed: {e}"),
});
}
let action = match &mut self.state {
State::Picker { cameras, error } => render_picker(ui, cameras, error.as_deref()),
State::Previewing {
capture,
camera_name,
} => {
if let Some(err) = capture.take_error() {
Action::BackToPicker(Some(err))
} else {
render_preview(
ui,
capture,
camera_name,
&self.settings,
&mut self.texture,
&mut self.last_frame,
self.status.as_deref(),
&self.snap_tx,
&self.snap_in_flight,
)
}
}
};
self.apply(action, &ctx);
}
fn on_exit(&mut self) {
self.stop_capture();
}
}
fn render_picker(ui: &mut egui::Ui, cameras: &[CameraOption], error: Option<&str>) -> Action {
let ctx = ui.ctx().clone();
let mut action = Action::None;
const NUM_KEYS: [egui::Key; 9] = [
egui::Key::Num1,
egui::Key::Num2,
egui::Key::Num3,
egui::Key::Num4,
egui::Key::Num5,
egui::Key::Num6,
egui::Key::Num7,
egui::Key::Num8,
egui::Key::Num9,
];
for (i, key) in NUM_KEYS.iter().enumerate() {
if i < cameras.len() && ctx.input(|input| input.key_pressed(*key)) {
action = Action::Start(cameras[i].clone());
}
}
egui::CentralPanel::default().show_inside(ui, |ui| {
ui.heading("camshooter — select a webcam");
ui.add_space(8.0);
if let Some(err) = error {
ui.colored_label(egui::Color32::LIGHT_RED, err);
ui.add_space(8.0);
}
if cameras.is_empty() {
ui.label("No webcams detected.");
if ui.button("Refresh").clicked() {
action = Action::BackToPicker(None); }
} else {
for (i, cam) in cameras.iter().enumerate() {
if ui.button(format!("{}. {}", i + 1, cam.name)).clicked() {
action = Action::Start(cam.clone());
}
}
ui.add_space(8.0);
ui.weak("Tip: press 1–9 to pick by number.");
}
});
action
}
#[allow(clippy::too_many_arguments)]
fn render_preview(
ui: &mut egui::Ui,
capture: &CaptureHandle,
camera_name: &str,
settings: &SnapshotSettings,
texture: &mut Option<egui::TextureHandle>,
last_frame: &mut Option<RgbImage>,
status: Option<&str>,
snap_tx: &Sender<SnapResult>,
snap_in_flight: &Arc<AtomicBool>,
) -> Action {
let ctx = ui.ctx().clone();
if let Some(frame) = capture.latest_frame() {
let size = [frame.width() as usize, frame.height() as usize];
let color = egui::ColorImage::from_rgb(size, frame.as_raw());
match texture {
Some(tex) => tex.set(color, egui::TextureOptions::LINEAR),
None => {
*texture = Some(ctx.load_texture("preview", color, egui::TextureOptions::LINEAR))
}
}
*last_frame = Some(frame);
}
let snap = ctx.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::S));
let quit = ctx.input(|i| i.key_pressed(egui::Key::Q));
let back = ctx.input(|i| i.key_pressed(egui::Key::Escape));
if snap
&& let Some(frame) = last_frame.clone()
&& !snap_in_flight.swap(true, Ordering::AcqRel)
{
let settings = settings.clone();
let tx = snap_tx.clone();
let ctx2 = ctx.clone();
let in_flight = Arc::clone(snap_in_flight);
std::thread::spawn(move || {
let result = save_snapshot(&frame, &settings).map_err(|e| e.to_string());
in_flight.store(false, Ordering::Release);
let _ = tx.send(result);
ctx2.request_repaint(); });
}
egui::Panel::top("info").show_inside(ui, |ui| {
ui.horizontal(|ui| {
ui.label(format!("● {camera_name}"));
ui.separator();
ui.weak("Space/S = snapshot · Esc = back · Q = quit");
});
});
egui::Panel::bottom("status").show_inside(ui, |ui| {
ui.label(status.unwrap_or(""));
});
egui::CentralPanel::default().show_inside(ui, |ui| {
if let Some(tex) = texture {
ui.add(
egui::Image::new(egui::load::SizedTexture::new(tex.id(), tex.size_vec2()))
.maintain_aspect_ratio(true)
.fit_to_exact_size(ui.available_size()),
);
} else {
ui.centered_and_justified(|ui| ui.label("Starting camera…"));
}
});
ctx.request_repaint_after(Duration::from_millis(100));
if quit {
Action::Quit
} else if back {
Action::BackToPicker(None)
} else {
Action::None
}
}