@@ -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
2832const 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
6166const 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