feat: cancel_recording, recording_playback_path, live sync progress

This commit is contained in:
iamdoubz
2026-07-06 17:51:57 -05:00
parent e7f628ad7a
commit da25ccaec6
+114 -7
View File
@@ -640,6 +640,58 @@ pub async fn stop_recording(
Ok(())
}
/// Abandon the in-progress recording: stop capture, drop the transcript, and
/// delete the meeting row + its working files entirely — for a recording that
/// was started by mistake. Unlike `stop_recording`, nothing is finalized,
/// transcribed further, diarized, retained, or synced.
#[tauri::command]
pub async fn cancel_recording(
app: AppHandle,
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<()> {
let mut guard = state.session.lock().await;
let session = match guard.take() {
Some(s) if s.meeting_id == meeting_id => s,
Some(s) => {
*guard = Some(s);
return Err(WaError::new(
"recording",
"meeting_id does not match the active recording",
));
}
None => {
return Err(WaError::new(
"recording",
"no meeting is currently recording",
))
}
};
drop(guard);
// Stop both captures so their frame sinks drop and the transcription worker
// exits; join it so nothing is still touching the files we're about to delete.
let _ = WasapiCapture.stop(session.capture);
if let Some(mic) = session.mic_capture {
let _ = WasapiCapture.stop(mic);
}
let _ = session.transcription_worker.join();
// Remove the DB row + the whole meeting folder (working audio.wav included).
state
.store
.delete_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
crate::update_tray_tooltip(&app, "WhispAssist — idle");
let _ = app.emit(
"recording://state",
serde_json::json!({ "meetingId": meeting_id, "state": "cancelled", "elapsedMs": 0 }),
);
Ok(())
}
#[tauri::command]
pub async fn pause_recording(
app: AppHandle,
@@ -1586,6 +1638,38 @@ pub async fn delete_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Return a filesystem path to a playable `audio.wav` for the in-app player
/// (FR-REC-4). A plaintext recording plays in place; one sealed at rest (T8.8)
/// is decrypted to a sibling `audio.play.wav` (requires the vault unlocked).
/// Errors when the meeting has no retained recording.
#[tauri::command]
pub async fn recording_playback_path(meeting_id: MeetingId) -> WaResult<String> {
tauri::async_runtime::spawn_blocking(move || {
let wav = meeting_dir(&meeting_id).join("audio.wav");
if !wav.exists() {
return Err(WaError::new(
"recording",
"no saved recording for this meeting",
));
}
let raw = std::fs::read(&wav).map_err(|e| WaError::new("recording", e.to_string()))?;
if !crate::vault::is_sealed(&raw) {
return Ok(wav.to_string_lossy().into_owned());
}
// ponytail: writes a plaintext copy beside the sealed original for the
// player; it lives only until the meeting is deleted, and only appears
// once the user chooses to play an encrypted recording.
let plain = crate::vault::open(&raw).map_err(|_| {
WaError::new("recording", "unlock the vault to play this recording")
})?;
let play = meeting_dir(&meeting_id).join("audio.play.wav");
std::fs::write(&play, plain).map_err(|e| WaError::new("recording", e.to_string()))?;
Ok(play.to_string_lossy().into_owned())
})
.await
.map_err(|e| WaError::new("recording", e.to_string()))?
}
#[tauri::command]
pub async fn update_notes(
state: State<'_, AppState>,
@@ -2762,6 +2846,7 @@ async fn upload_job(
target: &dyn crate::sync::SyncTarget,
row: &crate::storage::SyncJobRow,
encrypt: bool,
mut on_progress: impl FnMut(u64, u64) + Send + 'static,
) -> Result<u64, crate::sync::SyncError> {
use crate::sync::SyncError;
let remote_dir = row
@@ -2771,6 +2856,22 @@ async fn upload_job(
.unwrap_or("");
target.ensure_dir(remote_dir).await?;
let (tx, rx) = std::sync::mpsc::channel();
// Forward live upload progress off the async task (the sink is a blocking
// std channel): throttled so a large recording emits a steady stream of
// `sync://job` updates without flooding the UI. Ends when `put` drops `tx`.
let fallback_total = row.bytes_total.unwrap_or(0) as u64;
let drain = std::thread::spawn(move || {
let mut last = 0u64;
let mut last_emit = std::time::Instant::now() - std::time::Duration::from_millis(500);
while let Ok((sent, total)) = rx.recv() {
last = sent;
if last_emit.elapsed() >= std::time::Duration::from_millis(250) {
on_progress(sent, total);
last_emit = std::time::Instant::now();
}
}
last
});
if encrypt {
// Client-side encryption before upload (T9.12, FR-SYNC-10): seal to a
// temp file (idempotent if already sealed at rest) so the destination
@@ -2792,12 +2893,10 @@ async fn upload_job(
.put(Path::new(&row.local_path), &row.remote_path, tx)
.await?;
}
let sent = rx
.try_iter()
.last()
.map(|(s, _)| s)
.unwrap_or(row.bytes_total.unwrap_or(0) as u64);
Ok(sent)
// `tx` is dropped now that `put` returned, so the drain thread finishes and
// hands back the final byte count it observed.
let sent = drain.join().unwrap_or(fallback_total);
Ok(if sent > 0 { sent } else { fallback_total })
}
/// Drive all due jobs once: upload each, emit `sync://job` on every transition,
@@ -2822,8 +2921,16 @@ pub(crate) async fn pump_sync(app: &AppHandle, store: &dyn crate::storage::Store
let _ = store.update_sync_job(job.clone()).await;
emit_sync_job(app, &job);
// Emit a live `sync://job` (status still "uploading") as bytes go out, so
// the UI shows a moving per-item progress bar (FR-SYNC-11).
let app_prog = app.clone();
let mut prog_row = job.clone();
let on_progress = move |sent: u64, _total: u64| {
prog_row.bytes_sent = sent as i64;
emit_sync_job(&app_prog, &prog_row);
};
let outcome = match crate::sync::build_sync_target(&target) {
Ok(t) => upload_job(t.as_ref(), &job, target.encrypt_before_upload).await,
Ok(t) => upload_job(t.as_ref(), &job, target.encrypt_before_upload, on_progress).await,
Err(e) => Err(e),
};
match outcome {