camshooter 0.1.0

Select a webcam, preview the live stream, and grab PNG snapshots with a keypress.
//! The eframe application: a device picker that transitions into a live preview.

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};

/// Result of a background snapshot save, sent back to the UI for a status line.
type SnapResult = Result<PathBuf, String>;

/// Top-level application state.
enum State {
    /// Choosing a camera (or showing why we can't).
    Picker {
        cameras: Vec<CameraOption>,
        error: Option<String>,
    },
    /// Streaming from the selected camera.
    Previewing {
        capture: CaptureHandle,
        camera_name: String,
    },
}

/// What the current frame's input/rendering decided should happen to the state machine.
enum Action {
    None,
    Start(CameraOption),
    BackToPicker(Option<String>),
    Quit,
}

pub struct CamshooterApp {
    settings: SnapshotSettings,
    state: State,
    texture: Option<egui::TextureHandle>,
    /// Most recent frame, kept so a snapshot keypress always has pixels to save.
    last_frame: Option<RgbImage>,
    /// Last status message shown to the user (e.g. "Saved …" or an error).
    status: Option<String>,
    snap_tx: Sender<SnapResult>,
    snap_rx: Receiver<SnapResult>,
    /// True while a snapshot is being encoded/written, so a held key can't spawn an
    /// unbounded pile of worker threads (each holding a full-frame clone).
    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)),
        }
    }

    /// Apply a decided transition. This is where we may restructure `self.state` and
    /// touch the camera lifecycle (stop old thread before starting a new one).
    fn apply(&mut self, action: Action, ctx: &egui::Context) {
        match action {
            Action::None => {}
            Action::Start(opt) => {
                self.stop_capture(); // safe even if already in Picker
                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(); // release the device/LED before the window closes
                ctx.send_viewport_cmd(egui::ViewportCommand::Close);
            }
        }
    }

    /// Stop the capture thread if one is running (joins it, freeing the device).
    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) {
        // Cheap Arc clone; lets us call input/texture APIs without fighting the `ui` borrow.
        let ctx = ui.ctx().clone();

        // Drain any finished snapshot saves into the status line.
        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 the capture thread died, surface its error and return to the picker.
                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) {
        // Belt-and-suspenders: ensure the camera is released on any exit path.
        self.stop_capture();
    }
}

/// Render the device picker. Returns `Start(cam)` when the user picks one.
fn render_picker(ui: &mut egui::Ui, cameras: &[CameraOption], error: Option<&str>) -> Action {
    let ctx = ui.ctx().clone();
    let mut action = Action::None;

    // Number-key shortcuts 1..=9 select the Nth camera.
    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); // re-enumerates
            }
        } 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
}

/// Render the live preview. Returns `Quit` or `BackToPicker` on the relevant keys.
#[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();

    // Pull the freshest frame and push it to the GPU texture (reuse one handle).
    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);
    }

    // Input: snapshot / quit / back.
    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));

    // Encode + write off the UI thread so big frames don't stutter the preview. The
    // `swap` guard ensures only one snapshot is in flight at a time: a held key won't
    // spawn a pile of worker threads.
    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(); // wake the UI to show the status line
        });
    }

    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…"));
        }
    });

    // Heartbeat so we still notice a dead capture thread even if frames stop arriving.
    ctx.request_repaint_after(Duration::from_millis(100));

    if quit {
        Action::Quit
    } else if back {
        Action::BackToPicker(None)
    } else {
        Action::None
    }
}