Add libc dependency for unix targets
This commit adds the `libc` dependency to the `Cargo.toml` file for Unix-based targets. This dependency is required by the `ffmpeg` command execution within the `recorder` module, specifically for handling process management and signaling on Unix-like systems.
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
//! The egui application: config panel, record/stop buttons, state
|
||||
//! transitions driven by recorder + uploader channels.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use doctate_common::timestamp::{now_rfc3339, recorded_at_to_filename_stem};
|
||||
use eframe::egui;
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::recorder::{Recorder, RecorderEvent};
|
||||
use crate::state::AppState;
|
||||
use crate::uploader::{PendingUpload, UploadEvent, Uploader, write_sidecar};
|
||||
|
||||
pub struct DoctateApp {
|
||||
runtime: Arc<Runtime>,
|
||||
config: Option<Config>,
|
||||
pending_dir: PathBuf,
|
||||
state: AppState,
|
||||
|
||||
server_url_input: String,
|
||||
api_key_input: String,
|
||||
|
||||
recorder: Option<Recorder>,
|
||||
recorder_events: Option<mpsc::UnboundedReceiver<RecorderEvent>>,
|
||||
|
||||
uploader: Option<Uploader>,
|
||||
upload_events: Option<mpsc::UnboundedReceiver<UploadEvent>>,
|
||||
}
|
||||
|
||||
enum UiAction {
|
||||
SaveConfig,
|
||||
StartNew,
|
||||
Stop,
|
||||
DismissError,
|
||||
}
|
||||
|
||||
impl DoctateApp {
|
||||
pub fn new(runtime: Arc<Runtime>, pending_dir: PathBuf) -> Self {
|
||||
let config = match Config::load() {
|
||||
Ok(Some(c)) => Some(c),
|
||||
Ok(None) => None,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "config load failed");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let server_url_input = config
|
||||
.as_ref()
|
||||
.map(|c| c.server_url.clone())
|
||||
.unwrap_or_default();
|
||||
let api_key_input = config
|
||||
.as_ref()
|
||||
.map(|c| c.api_key.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let (state, uploader, upload_events) = if let Some(cfg) = &config {
|
||||
let (up, rx) = spawn_uploader(&runtime, cfg.clone(), pending_dir.clone());
|
||||
(AppState::Idle, Some(up), Some(rx))
|
||||
} else {
|
||||
(AppState::NotConfigured, None, None)
|
||||
};
|
||||
|
||||
Self {
|
||||
runtime,
|
||||
config,
|
||||
pending_dir,
|
||||
state,
|
||||
server_url_input,
|
||||
api_key_input,
|
||||
recorder: None,
|
||||
recorder_events: None,
|
||||
uploader,
|
||||
upload_events,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_save_config(&mut self) {
|
||||
let cfg = Config {
|
||||
server_url: self.server_url_input.trim().to_string(),
|
||||
api_key: self.api_key_input.trim().to_string(),
|
||||
};
|
||||
match cfg.save() {
|
||||
Ok(()) => {
|
||||
info!("config saved");
|
||||
let (up, rx) = spawn_uploader(&self.runtime, cfg.clone(), self.pending_dir.clone());
|
||||
self.uploader = Some(up);
|
||||
self.upload_events = Some(rx);
|
||||
self.config = Some(cfg);
|
||||
self.state = AppState::Idle;
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = %e, "config save failed");
|
||||
self.state = AppState::Error {
|
||||
message: format!("Speichern: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_start_new(&mut self) {
|
||||
if let Err(e) = std::fs::create_dir_all(&self.pending_dir) {
|
||||
self.state = AppState::Error {
|
||||
message: format!("Pending-Verzeichnis: {e}"),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
let case_id = Uuid::new_v4();
|
||||
let recorded_at = now_rfc3339();
|
||||
let stem = format!("{}_{}", case_id, recorded_at_to_filename_stem(&recorded_at));
|
||||
let output_path = self.pending_dir.join(format!("{stem}.m4a"));
|
||||
|
||||
let _guard = self.runtime.enter();
|
||||
match Recorder::start(output_path) {
|
||||
Ok((recorder, events)) => {
|
||||
self.recorder = Some(recorder);
|
||||
self.recorder_events = Some(events);
|
||||
self.state = AppState::Recording {
|
||||
case_id,
|
||||
recorded_at,
|
||||
started_at: Instant::now(),
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
self.state = AppState::Error {
|
||||
message: format!("Aufnahme starten: {e}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_stop(&mut self) {
|
||||
let AppState::Recording {
|
||||
case_id,
|
||||
recorded_at,
|
||||
..
|
||||
} = &self.state
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let case_id = *case_id;
|
||||
let recorded_at = recorded_at.clone();
|
||||
if let Some(rec) = self.recorder.take() {
|
||||
rec.stop();
|
||||
}
|
||||
self.state = AppState::FinalizingRecording {
|
||||
case_id,
|
||||
recorded_at,
|
||||
};
|
||||
}
|
||||
|
||||
fn drain_recorder_events(&mut self) {
|
||||
let Some(rx) = self.recorder_events.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
for event in events {
|
||||
match event {
|
||||
RecorderEvent::Started => {}
|
||||
RecorderEvent::Finished { output } => {
|
||||
self.handle_recording_finished(output);
|
||||
}
|
||||
RecorderEvent::Failed { error: err } => {
|
||||
error!(error = %err, "recorder failed");
|
||||
self.state = AppState::Error {
|
||||
message: format!("Aufnahme: {err}"),
|
||||
};
|
||||
self.recorder_events = None;
|
||||
self.recorder = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_recording_finished(&mut self, output: PathBuf) {
|
||||
let (case_id, recorded_at) = match &self.state {
|
||||
AppState::FinalizingRecording {
|
||||
case_id,
|
||||
recorded_at,
|
||||
} => (*case_id, recorded_at.clone()),
|
||||
AppState::Recording {
|
||||
case_id,
|
||||
recorded_at,
|
||||
..
|
||||
} => (*case_id, recorded_at.clone()),
|
||||
other => {
|
||||
warn!(state = ?other, "recorder finished in unexpected state");
|
||||
self.recorder_events = None;
|
||||
self.recorder = None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let upload = PendingUpload {
|
||||
case_id,
|
||||
recorded_at,
|
||||
file: output,
|
||||
};
|
||||
|
||||
if let Err(e) = write_sidecar(&upload) {
|
||||
self.state = AppState::Error {
|
||||
message: format!("Sidecar schreiben: {e}"),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(uploader) = &self.uploader else {
|
||||
self.state = AppState::Error {
|
||||
message: "Upload-Worker nicht verfügbar".into(),
|
||||
};
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(e) = uploader.submit(upload) {
|
||||
self.state = AppState::Error {
|
||||
message: format!("Upload-Submit: {e}"),
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
self.state = AppState::Uploading { case_id };
|
||||
self.recorder_events = None;
|
||||
self.recorder = None;
|
||||
}
|
||||
|
||||
fn drain_upload_events(&mut self) {
|
||||
let Some(rx) = self.upload_events.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut events = Vec::new();
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
events.push(event);
|
||||
}
|
||||
for event in events {
|
||||
match event {
|
||||
UploadEvent::Started(case_id) => {
|
||||
info!(case_id = %case_id, "upload attempt started");
|
||||
}
|
||||
UploadEvent::Succeeded { case_id, status } => {
|
||||
info!(case_id = %case_id, status = ?status, "upload succeeded");
|
||||
if let AppState::Uploading { case_id: current } = &self.state
|
||||
&& *current == case_id
|
||||
{
|
||||
self.state = AppState::Idle;
|
||||
}
|
||||
}
|
||||
UploadEvent::Failed {
|
||||
case_id,
|
||||
reason,
|
||||
will_retry,
|
||||
} => {
|
||||
warn!(case_id = %case_id, reason = %reason, will_retry, "upload failed");
|
||||
if !will_retry
|
||||
&& let AppState::Uploading { case_id: current } = &self.state
|
||||
&& *current == case_id
|
||||
{
|
||||
self.state = AppState::Error { message: reason };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_body(&mut self, ui: &mut egui::Ui) -> Option<UiAction> {
|
||||
match &self.state {
|
||||
AppState::NotConfigured => {
|
||||
ui.label("Server URL:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.server_url_input).desired_width(280.0),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label("API Key:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.api_key_input)
|
||||
.desired_width(280.0)
|
||||
.password(true),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
if ui.button("Speichern").clicked() {
|
||||
return Some(UiAction::SaveConfig);
|
||||
}
|
||||
None
|
||||
}
|
||||
AppState::Idle => {
|
||||
if ui.button("● Neu").clicked() {
|
||||
return Some(UiAction::StartNew);
|
||||
}
|
||||
None
|
||||
}
|
||||
AppState::Recording { started_at, .. } => {
|
||||
let elapsed = started_at.elapsed();
|
||||
ui.label(format!(
|
||||
"● REC {:02}:{:02}",
|
||||
elapsed.as_secs() / 60,
|
||||
elapsed.as_secs() % 60
|
||||
));
|
||||
ui.add_space(4.0);
|
||||
if ui.button("■ Stop").clicked() {
|
||||
return Some(UiAction::Stop);
|
||||
}
|
||||
None
|
||||
}
|
||||
AppState::FinalizingRecording { .. } => {
|
||||
ui.spinner();
|
||||
ui.label("Speichere Aufnahme…");
|
||||
None
|
||||
}
|
||||
AppState::Uploading { .. } => {
|
||||
ui.spinner();
|
||||
ui.label("Upload läuft…");
|
||||
None
|
||||
}
|
||||
AppState::Error { message } => {
|
||||
ui.colored_label(egui::Color32::RED, message);
|
||||
ui.add_space(8.0);
|
||||
if ui.button("OK").clicked() {
|
||||
return Some(UiAction::DismissError);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_action(&mut self, action: UiAction) {
|
||||
match action {
|
||||
UiAction::SaveConfig => self.on_save_config(),
|
||||
UiAction::StartNew => self.on_start_new(),
|
||||
UiAction::Stop => self.on_stop(),
|
||||
UiAction::DismissError => self.state = AppState::Idle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_uploader(
|
||||
runtime: &Runtime,
|
||||
config: Config,
|
||||
pending_dir: PathBuf,
|
||||
) -> (Uploader, mpsc::UnboundedReceiver<UploadEvent>) {
|
||||
let _guard = runtime.enter();
|
||||
Uploader::spawn(config, pending_dir)
|
||||
}
|
||||
|
||||
impl eframe::App for DoctateApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.drain_recorder_events();
|
||||
self.drain_upload_events();
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.heading("doctate");
|
||||
ui.add_space(4.0);
|
||||
ui.label(self.state.label());
|
||||
ui.add_space(12.0);
|
||||
|
||||
let action = self.render_body(ui);
|
||||
if let Some(action) = action {
|
||||
self.handle_action(action);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
match self.state {
|
||||
AppState::Recording { .. } => ctx.request_repaint_after(Duration::from_millis(250)),
|
||||
AppState::FinalizingRecording { .. } | AppState::Uploading { .. } => {
|
||||
ctx.request_repaint_after(Duration::from_millis(100));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user