From 2631845b027a00aa7517181415f8ffc632f198af Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:02:47 +0100 Subject: [PATCH 1/3] fix: preserve paused Studio recordings after failed resume --- apps/desktop-gpui/src/controls_window.rs | 85 ++++- .../src/routes/in-progress-recording.tsx | 118 +++--- crates/recording/src/instant_recording.rs | 2 +- crates/recording/src/output_pipeline/core.rs | 138 ++++--- crates/recording/src/studio_recording.rs | 345 ++++++++++++++++-- .../tests/hardware_studio_recording.rs | 100 +++++ 6 files changed, 637 insertions(+), 151 deletions(-) diff --git a/apps/desktop-gpui/src/controls_window.rs b/apps/desktop-gpui/src/controls_window.rs index b5460fe54e..6a5da71b3a 100644 --- a/apps/desktop-gpui/src/controls_window.rs +++ b/apps/desktop-gpui/src/controls_window.rs @@ -105,7 +105,9 @@ impl ControlsWindow { let theme = self.theme; div() .id(id) - .size(px(32.)) + .w(px(28.)) + .h(px(32.)) + .flex_shrink_0() .flex() .items_center() .justify_center() @@ -128,7 +130,6 @@ impl ControlsWindow { let stopping = session.phase == Phase::Stopping; let countdown = session.countdown_remaining(); let can_stop = (!starting && !stopping) || countdown.is_some(); - let error = session.error.clone(); let label: SharedString = if let Some(countdown) = countdown { countdown.to_string().into() } else if starting { @@ -142,23 +143,25 @@ impl ControlsWindow { "Pausing…" } .into() - } else if error.is_some() { - "Error".into() } else if session.is_paused() { "Paused".into() + } else if session.error.is_some() { + "Error".into() } else { Self::format_elapsed(session.elapsed()).into() }; div() .id("stop") + .min_w_0() + .flex_1() .flex() .flex_row() .items_center() .gap(px(4.)) .rounded(px(8.)) .py(px(4.)) - .px(px(8.)) + .px(px(6.)) .text_color(theme.red_300) .when(can_stop, |this| { this.hover(|style| style.bg(Theme::with_alpha(theme.red_300, 0.08))) @@ -168,17 +171,17 @@ impl ControlsWindow { })) }) .when(!can_stop, |this| this.opacity(0.6)) - .when_some(error, |this, error| { - this.tooltip(move |_, cx| ui::Tooltip::new(&theme, error.clone()).view(cx)) - }) .child( svg() .path("icons/stop-circle.svg") - .size(px(16.)) + .size(px(20.)) + .flex_shrink_0() .text_color(theme.red_300), ) .child( div() + .min_w_0() + .truncate() .text_size(px(14.)) .font_weight(gpui::FontWeight::MEDIUM) .child(label), @@ -215,7 +218,9 @@ impl ControlsWindow { div() .id("microphone") - .size(px(32.)) + .w(px(28.)) + .h(px(32.)) + .flex_shrink_0() .relative() .flex() .items_center() @@ -352,6 +357,7 @@ impl ControlsWindow { div() .h(px(40.)) + .flex_shrink_0() .w_full() .flex() .flex_row() @@ -373,9 +379,11 @@ impl ControlsWindow { div() .flex() .flex_1() + .min_w_0() .flex_row() .items_center() .justify_between() + .gap(px(4.)) .p(px(4.)) .child(self.render_stop(cx)) .child( @@ -383,7 +391,8 @@ impl ControlsWindow { .flex() .flex_row() .items_center() - .gap(px(4.)) + .gap(px(2.)) + .flex_shrink_0() .child(self.render_microphone(cx)) .child( self.action_button( @@ -440,6 +449,8 @@ impl ControlsWindow { .child( div() .id("drag") + .w(px(24.)) + .flex_shrink_0() .cursor_move() .flex() .items_center() @@ -465,29 +476,67 @@ impl ControlsWindow { impl Render for ControlsWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.theme.refresh(window, cx, false); + let session = self.session.read(cx); + let issue = session.error.clone().or_else(|| { + session + .storage_warning + .then(|| "Low storage. Cap will stop soon to save your recording.".to_owned()) + }); div() .size_full() .flex() .flex_col() .justify_end() - .px(px(12.)) - .pb(px(12.)) + .p(px(12.)) .font_family("Geist") // `body { font-weight: 500 }` (`ui-solid/src/main.css:189-192`). .font_weight(FontWeight::MEDIUM) - .when(self.session.read(cx).storage_warning, |this| { + .when_some(issue, |this, issue| { this.child( div() + .min_h_0() + .w_full() + .flex() + .items_start() + .gap(px(8.)) .mb(px(8.)) - .rounded(px(8.)) - .bg(self.theme.red_2) + .rounded(px(12.)) + .border_1() + .border_color(Theme::with_alpha(self.theme.red_9, 0.4)) + .bg(self.theme.gray_1) .p(px(8.)) - .text_size(px(11.)) + .text_size(px(12.)) .text_color(self.theme.red_11) - .child("Low storage. Cap will stop soon to save your recording."), + .child( + svg() + .path("icons/triangle-alert.svg") + .size(px(20.)) + .flex_shrink_0() + .text_color(self.theme.red_9), + ) + .child(recording_issue_text(issue)), ) }) .child(self.render_bar(cx)) } } + +fn recording_issue_text(error: String) -> impl IntoElement + Styled { + div() + .id("recording-issue-text") + .min_w_0() + .min_h_0() + .flex_1() + .max_h(px(56.)) + .overflow_x_scroll() + .overflow_y_scroll() + .child(if error.contains("Insufficient disk space") + && error.contains("Your recording is still paused") + { + "Not enough disk space to resume. Free up space and try again, or press Stop to save." + .to_owned() + } else { + error + }) +} diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index 47de69bdb9..d74d71de18 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -1,3 +1,5 @@ +import "@radix-ui/colors/amber.css"; +import "@radix-ui/colors/amber-dark.css"; import { createTimer } from "@solid-primitives/timer"; import { createMutation } from "@tanstack/solid-query"; import { LogicalPosition } from "@tauri-apps/api/dpi"; @@ -208,11 +210,13 @@ function InProgressRecordingInner() { if (failure) issues.push(failure); const controlError = pauseError(); if (controlError) issues.push(controlError); + const degraded = degradedReason(); + if (degraded) issues.push(degraded); const nativeError = cleanCapture.data?.error; if (nativeError) issues.push(nativeError); const localError = stopError(); if (localError) issues.push(localError); - return [...new Set(issues)]; + return [...new Set(issues.map(recordingIssueMessage))]; }); const hasRecordingIssue = () => issueMessages().length > 0; @@ -579,7 +583,9 @@ function InProgressRecordingInner() { (state().variant === "recording" || state().variant === "paused") ) { setPauseError( - `Could not ${request.resume ? "resume" : "pause"} recording: ${String(error)}`, + String(error).startsWith("Could not ") + ? String(error) + : `Could not ${request.resume ? "resume" : "pause"} recording: ${String(error)}`, ); } throw error; @@ -874,15 +880,18 @@ function InProgressRecordingInner() { }; return ( -
-
+
+
-
+
-
-
-
+
+
+
- + Starting
} > { if (event.button !== 0) return; @@ -931,8 +949,8 @@ function InProgressRecordingInner() { title="Stop recording" aria-label="Stop recording" > - - + + -
+
{optionsQuery.rawOptions.micName != null ? ( disconnectedInputs.microphone ? ( - + ) : ( <> @@ -1043,7 +1061,7 @@ function InProgressRecordingInner() { > toggleMicMute.mutate()} title={ @@ -1073,32 +1091,6 @@ function InProgressRecordingInner() { )} - - -
- -
-
-
- - {(reason) => ( - -
-
-
- - )} -
- +
@@ -1216,9 +1209,20 @@ function InProgressRecordingInner() { ); } +function recordingIssueMessage(message: string): string { + if ( + message.includes("Insufficient disk space") && + message.includes("Your recording is still paused") + ) { + return "Not enough disk space to resume. Free up space and try again, or press Stop to save."; + } + return message; +} + function RecordingControlTooltip(props: { content: string | undefined; children: JSX.Element; + flexible?: boolean; }) { const [open, setOpen] = createSignal(false); const boundsPrefix = `recording-control-tooltip-${createUniqueId()}`; @@ -1284,7 +1288,10 @@ function RecordingControlTooltip(props: { {props.content} } - childClass="flex h-full items-center" + childClass={cx( + "flex h-full min-w-0 items-center", + props.flexible ? "flex-1" : "shrink-0", + )} placement="top" gutter={6} flip={false} @@ -1294,7 +1301,7 @@ function RecordingControlTooltip(props: { onOpenChange={setOpen} > setOpen(true)} onFocusOut={(event) => { if ( @@ -1312,13 +1319,18 @@ function RecordingControlTooltip(props: { ); } -function RecordingControlButton(props: ComponentProps<"button">) { - const [local, buttonProps] = splitProps(props, ["title"]); +function RecordingControlButton( + props: ComponentProps<"button"> & { flexible?: boolean }, +) { + const [local, buttonProps] = splitProps(props, ["title", "flexible"]); return ( - +