feat: add limits to recordings on destop

This commit is contained in:
Arjun Patel
2026-06-20 14:26:39 -07:00
parent c11c5074ce
commit 543b5ffa60
3 changed files with 114 additions and 9 deletions
@@ -7,6 +7,10 @@ import { useObjectUrl } from '@/hooks/use-object-url';
import { AttachmentStrip } from '@/features/compose/attachment-strip'; import { AttachmentStrip } from '@/features/compose/attachment-strip';
import type { PendingAttachment } from '@/features/compose/attachment-strip'; import type { PendingAttachment } from '@/features/compose/attachment-strip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import {
RECORDING_MAX_DURATION_SECONDS,
RECORDING_WARNING_SECONDS,
} from '@/lib/constants';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { KeyHint } from '@/components/key-hint'; import { KeyHint } from '@/components/key-hint';
import { useComposeIntentStore } from '@/stores/compose-intent-store'; import { useComposeIntentStore } from '@/stores/compose-intent-store';
@@ -34,16 +38,51 @@ interface RecordingOverlayProps {
objectFit?: 'cover' | 'contain'; objectFit?: 'cover' | 'contain';
} }
function RecordingTimer() { const WARNING_AT_SECONDS =
const [elapsed, setElapsed] = useState(0); RECORDING_MAX_DURATION_SECONDS - RECORDING_WARNING_SECONDS;
/**
* Tracks elapsed recording time and drives the time-limit UI. Keeps the
* recorder itself unaware of limits: when the cap is reached it dispatches the
* standard `stop` intent (the same path as releasing the ` key), which finishes
* the recording into the review step.
*/
function useRecordingCountdown(active: boolean) {
const [{ elapsed, isWarning }, setState] = useState({
elapsed: 0,
isWarning: false,
});
const requestIntent = useComposeIntentStore((s) => s.request);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { if (!active) return;
setElapsed((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
const start = Date.now();
let stopped = false;
const interval = setInterval(() => {
const seconds = Math.floor((Date.now() - start) / 1000);
setState({ elapsed: seconds, isWarning: seconds >= WARNING_AT_SECONDS });
if (seconds >= RECORDING_MAX_DURATION_SECONDS && !stopped) {
stopped = true;
requestIntent('stop');
}
}, 250);
return () => {
clearInterval(interval);
setState({ elapsed: 0, isWarning: false });
};
}, [active, requestIntent]);
return { elapsed, isWarning };
}
function RecordingTimer({
elapsed,
isWarning,
}: {
elapsed: number;
isWarning: boolean;
}) {
const minutes = Math.floor(elapsed / 60); const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60; const seconds = elapsed % 60;
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`; const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
@@ -51,7 +90,14 @@ function RecordingTimer() {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" /> <span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
<span className="font-mono text-sm text-white/80">{display}</span> <span
className={cn(
'font-mono text-sm text-white/80',
isWarning && 'text-red-400',
)}
>
{display}
</span>
</div> </div>
); );
} }
@@ -137,14 +183,31 @@ export function RecordingOverlay({
const isLoading = isRecording && !mediaStream; const isLoading = isRecording && !mediaStream;
const requestIntent = useComposeIntentStore((s) => s.request); const requestIntent = useComposeIntentStore((s) => s.request);
const { elapsed, isWarning } = useRecordingCountdown(
isRecording && !isLoading,
);
return ( return (
<div <div
className={cn( className={cn(
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90', 'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90',
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30', isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
isRecording && isWarning && 'record-warning-glow',
)} )}
{...(isReviewing ? dropZoneProps : {})} {...(isReviewing ? dropZoneProps : {})}
> >
{/* Top progress bar: fills over the recording duration, red in warning */}
{isRecording && !isLoading && (
<div className="absolute inset-x-0 top-0 z-20 h-1 bg-white/10">
<div
className={cn(
'h-full w-full origin-left record-progress',
isWarning ? 'bg-red-500' : 'bg-white/80',
)}
style={{ animationDuration: `${RECORDING_MAX_DURATION_SECONDS}s` }}
/>
</div>
)}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="z-10 flex flex-col items-center gap-2"> <div className="z-10 flex flex-col items-center gap-2">
@@ -185,7 +248,7 @@ export function RecordingOverlay({
{/* Top center: recording indicator */} {/* Top center: recording indicator */}
<div className="absolute top-8 z-10"> <div className="absolute top-8 z-10">
{isRecording && !isLoading ? ( {isRecording && !isLoading ? (
<RecordingTimer /> <RecordingTimer elapsed={elapsed} isWarning={isWarning} />
) : isReviewing ? ( ) : isReviewing ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-white/80">Review recording</span> <span className="text-sm text-white/80">Review recording</span>
+6
View File
@@ -4,6 +4,12 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024;
/** Maximum number of file attachments per particle. */ /** Maximum number of file attachments per particle. */
export const MAX_ATTACHMENTS = 10; export const MAX_ATTACHMENTS = 10;
/** Maximum duration for a media (audio/video) recording, in seconds. */
export const RECORDING_MAX_DURATION_SECONDS = 60;
/** When this many seconds or fewer remain, show the red warning state. */
export const RECORDING_WARNING_SECONDS = 10;
export const SUPPORT_EMAIL = 'team@flowylabs.ai'; export const SUPPORT_EMAIL = 'team@flowylabs.ai';
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy'; export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
+36
View File
@@ -186,3 +186,39 @@
.no-drag { .no-drag {
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
/* Recording time-limit progress bar: a single CSS animation fills the bar
left→right over the recording duration (duration set inline), avoiding
per-frame React renders. */
@keyframes record-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
.record-progress {
transform: scaleX(0);
animation-name: record-progress;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
/* Pulsing red glow around the recording overlay during the final warning window. */
@keyframes record-warning-glow {
0%,
100% {
box-shadow:
inset 0 0 0 2px var(--destructive),
inset 0 0 24px 0 oklch(0.6 0.24 27 / 0.25);
}
50% {
box-shadow:
inset 0 0 0 3px var(--destructive),
inset 0 0 60px 0 oklch(0.6 0.24 27 / 0.5);
}
}
.record-warning-glow {
animation: record-warning-glow 1s ease-in-out infinite;
}