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:
2026-04-17 16:23:04 +02:00
parent 5f7e46256c
commit d0f70e706e
6 changed files with 590 additions and 39 deletions
+381
View File
@@ -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));
}
_ => {}
}
}
}