feat: cancel_recording, recording_playback_path, live sync progress
This commit is contained in:
+114
-7
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user