Skip to content

Commit 3cfb332

Browse files
Add error download option for failed recordings (#1369)
* Add error download option for failed recordings * Enable restart on error and improve file extension handling * Fix subtype extraction in file extension utility
1 parent bb1c5ec commit 3cfb332

File tree

4 files changed

+224
-88
lines changed

4 files changed

+224
-88
lines changed

apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/InProgressRecordingBar.tsx

Lines changed: 111 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ import {
2323
PopoverContent,
2424
PopoverTrigger,
2525
} from "@/components/ui/popover";
26-
import type { ChunkUploadState, RecorderPhase } from "./web-recorder-types";
26+
import type {
27+
ChunkUploadState,
28+
RecorderPhase,
29+
RecordingFailureDownload,
30+
} from "./web-recorder-types";
2731

2832
const phaseMessages: Partial<Record<RecorderPhase, string>> = {
2933
recording: "Recording",
@@ -56,6 +60,7 @@ interface InProgressRecordingBarProps {
5660
onResume?: () => void | Promise<void>;
5761
onRestart?: () => void | Promise<void>;
5862
isRestarting?: boolean;
63+
errorDownload?: RecordingFailureDownload | null;
5964
}
6065

6166
const DRAG_PADDING = 12;
@@ -70,6 +75,7 @@ export const InProgressRecordingBar = ({
7075
onResume,
7176
onRestart,
7277
isRestarting = false,
78+
errorDownload,
7379
}: InProgressRecordingBarProps) => {
7480
const [mounted, setMounted] = useState(false);
7581
const [position, setPosition] = useState({ x: 0, y: 24 });
@@ -182,8 +188,9 @@ export const InProgressRecordingBar = ({
182188
}
183189

184190
const isPaused = phase === "paused";
185-
const canStop = phase === "recording" || isPaused;
186-
const showTimer = phase === "recording" || isPaused;
191+
const isErrorState = phase === "error";
192+
const canStop = (phase === "recording" || isPaused) && !isErrorState;
193+
const showTimer = (phase === "recording" || isPaused) && !isErrorState;
187194
const statusText = showTimer
188195
? formatDuration(durationMs)
189196
: (phaseMessages[phase] ?? "Processing");
@@ -246,6 +253,7 @@ export const InProgressRecordingBar = ({
246253
};
247254

248255
return createPortal(
256+
// biome-ignore lint/a11y/noStaticElementInteractions: The floating recorder bar must capture pointer events for drag without extra key handlers.
249257
<div
250258
ref={containerRef}
251259
className={clsx(
@@ -254,71 +262,111 @@ export const InProgressRecordingBar = ({
254262
)}
255263
style={{ left: `${position.x}px`, top: `${position.y}px` }}
256264
onMouseDown={handlePointerDown}
257-
role="status"
265+
role="presentation"
266+
tabIndex={-1}
258267
aria-live="polite"
259268
>
260269
<div className="flex flex-row items-stretch rounded-[0.9rem] border border-gray-5 bg-gray-1 text-gray-12 shadow-[0_16px_60px_rgba(0,0,0,0.35)] min-w-[360px]">
261-
<div className="flex flex-row justify-between flex-1 gap-3 p-[0.25rem]">
262-
<button
263-
type="button"
270+
{isErrorState ? (
271+
<div
272+
className="flex flex-1 items-center justify-between gap-3 p-3"
264273
data-no-drag
265-
onClick={handleStop}
266-
disabled={!canStop}
267-
className="py-[0.25rem] px-[0.5rem] text-red-300 gap-[0.35rem] flex flex-row items-center rounded-lg transition-opacity disabled:opacity-60"
268274
>
269-
<StopCircle className="size-5" />
270-
<span className="font-[500] text-[0.875rem] tabular-nums">
271-
{statusText}
272-
</span>
273-
</button>
274-
275-
<div className="flex gap-3 items-center" data-no-drag>
276-
<InlineChunkProgress chunkUploads={chunkUploads} />
277-
<div className="flex relative justify-center items-center w-8 h-8">
278-
{hasAudioTrack ? (
279-
<>
280-
<Mic className="size-5 text-gray-12" />
281-
<div className="absolute bottom-1 left-1 right-1 h-0.5 bg-gray-10 overflow-hidden rounded-full">
282-
<div
283-
className="absolute inset-0 bg-blue-9 transition-transform duration-200"
284-
style={{
285-
transform: hasAudioTrack
286-
? "translateX(0%)"
287-
: "translateX(-100%)",
288-
}}
289-
/>
290-
</div>
291-
</>
275+
<div className="flex flex-col text-left">
276+
<span className="text-[0.95rem] font-semibold text-red-11">
277+
Recording failed.
278+
</span>
279+
{errorDownload ? (
280+
<a
281+
href={errorDownload.url}
282+
download={errorDownload.fileName}
283+
className="text-[0.85rem] font-medium text-blue-11 underline underline-offset-2 hover:text-blue-12 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-9"
284+
>
285+
Download here.
286+
</a>
292287
) : (
293-
<MicOff className="text-gray-7 size-5" />
288+
<span className="text-[0.8rem] text-gray-11">
289+
Download unavailable.
290+
</span>
294291
)}
295292
</div>
296-
297-
<ActionButton
298-
data-no-drag
299-
onClick={handlePauseToggle}
300-
disabled={!canTogglePause}
301-
aria-label={isPaused ? "Resume recording" : "Pause recording"}
302-
>
303-
{isPaused ? (
304-
<PlayCircle className="size-5" />
305-
) : (
306-
<PauseCircle className="size-5" />
307-
)}
308-
</ActionButton>
309-
<ActionButton
293+
{Boolean(onRestart) && (canRestart || phase === "error") && (
294+
<ActionButton
295+
data-no-drag
296+
onClick={handleRestart}
297+
disabled={!(canRestart || phase === "error")}
298+
aria-label="Restart recording"
299+
aria-busy={isRestarting}
300+
>
301+
<RotateCcw
302+
className={clsx("size-5", isRestarting && "animate-spin")}
303+
/>
304+
</ActionButton>
305+
)}
306+
</div>
307+
) : (
308+
<div className="flex flex-row justify-between flex-1 gap-3 p-[0.25rem]">
309+
<button
310+
type="button"
310311
data-no-drag
311-
onClick={handleRestart}
312-
disabled={!canRestart}
313-
aria-label="Restart recording"
314-
aria-busy={isRestarting}
312+
onClick={handleStop}
313+
disabled={!canStop}
314+
className="py-[0.25rem] px-[0.5rem] text-red-300 gap-[0.35rem] flex flex-row items-center rounded-lg transition-opacity disabled:opacity-60"
315315
>
316-
<RotateCcw
317-
className={clsx("size-5", isRestarting && "animate-spin")}
318-
/>
319-
</ActionButton>
316+
<StopCircle className="size-5" />
317+
<span className="font-[500] text-[0.875rem] tabular-nums">
318+
{statusText}
319+
</span>
320+
</button>
321+
322+
<div className="flex gap-3 items-center" data-no-drag>
323+
<InlineChunkProgress chunkUploads={chunkUploads} />
324+
<div className="flex relative justify-center items-center w-8 h-8">
325+
{hasAudioTrack ? (
326+
<>
327+
<Mic className="size-5 text-gray-12" />
328+
<div className="absolute bottom-1 left-1 right-1 h-0.5 bg-gray-10 overflow-hidden rounded-full">
329+
<div
330+
className="absolute inset-0 bg-blue-9 transition-transform duration-200"
331+
style={{
332+
transform: hasAudioTrack
333+
? "translateX(0%)"
334+
: "translateX(-100%)",
335+
}}
336+
/>
337+
</div>
338+
</>
339+
) : (
340+
<MicOff className="text-gray-7 size-5" />
341+
)}
342+
</div>
343+
344+
<ActionButton
345+
data-no-drag
346+
onClick={handlePauseToggle}
347+
disabled={!canTogglePause}
348+
aria-label={isPaused ? "Resume recording" : "Pause recording"}
349+
>
350+
{isPaused ? (
351+
<PlayCircle className="size-5" />
352+
) : (
353+
<PauseCircle className="size-5" />
354+
)}
355+
</ActionButton>
356+
<ActionButton
357+
data-no-drag
358+
onClick={handleRestart}
359+
disabled={!canRestart}
360+
aria-label="Restart recording"
361+
aria-busy={isRestarting}
362+
>
363+
<RotateCcw
364+
className={clsx("size-5", isRestarting && "animate-spin")}
365+
/>
366+
</ActionButton>
367+
</div>
320368
</div>
321-
</div>
369+
)}
322370
<div
323371
className="cursor-move flex items-center justify-center p-[0.25rem] border-l border-gray-5 text-gray-9"
324372
aria-hidden
@@ -351,8 +399,7 @@ const InlineChunkProgress = ({
351399
}: {
352400
chunkUploads: ChunkUploadState[];
353401
}) => {
354-
if (chunkUploads.length === 0) return null;
355-
402+
const hasChunks = chunkUploads.length > 0;
356403
const completedCount = chunkUploads.filter(
357404
(chunk) => chunk.status === "complete",
358405
).length;
@@ -437,6 +484,10 @@ const InlineChunkProgress = ({
437484
error: "text-red-11",
438485
};
439486

487+
if (!hasChunks) {
488+
return null;
489+
}
490+
440491
return (
441492
<Popover
442493
open={isPopoverOpen}
@@ -465,6 +516,7 @@ const InlineChunkProgress = ({
465516
aria-label="Upload progress"
466517
>
467518
<svg className="h-5 w-5 -rotate-90" viewBox="0 0 36 36">
519+
<title>Upload progress</title>
468520
<circle
469521
className="fill-none stroke-gray-4"
470522
strokeWidth={4}

0 commit comments

Comments
 (0)