feat: add limits to recordings on destop
This commit is contained in:
@@ -7,6 +7,10 @@ import { useObjectUrl } from '@/hooks/use-object-url';
|
||||
import { AttachmentStrip } from '@/features/compose/attachment-strip';
|
||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
RECORDING_MAX_DURATION_SECONDS,
|
||||
RECORDING_WARNING_SECONDS,
|
||||
} from '@/lib/constants';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { KeyHint } from '@/components/key-hint';
|
||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||
@@ -34,16 +38,51 @@ interface RecordingOverlayProps {
|
||||
objectFit?: 'cover' | 'contain';
|
||||
}
|
||||
|
||||
function RecordingTimer() {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const WARNING_AT_SECONDS =
|
||||
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(() => {
|
||||
const interval = setInterval(() => {
|
||||
setElapsed((prev) => prev + 1);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
if (!active) return;
|
||||
|
||||
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 seconds = elapsed % 60;
|
||||
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
@@ -51,7 +90,14 @@ function RecordingTimer() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -137,14 +183,31 @@ export function RecordingOverlay({
|
||||
const isLoading = isRecording && !mediaStream;
|
||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||
|
||||
const { elapsed, isWarning } = useRecordingCountdown(
|
||||
isRecording && !isLoading,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90',
|
||||
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
|
||||
isRecording && isWarning && 'record-warning-glow',
|
||||
)}
|
||||
{...(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 */}
|
||||
{isLoading && (
|
||||
<div className="z-10 flex flex-col items-center gap-2">
|
||||
@@ -185,7 +248,7 @@ export function RecordingOverlay({
|
||||
{/* Top center: recording indicator */}
|
||||
<div className="absolute top-8 z-10">
|
||||
{isRecording && !isLoading ? (
|
||||
<RecordingTimer />
|
||||
<RecordingTimer elapsed={elapsed} isWarning={isWarning} />
|
||||
) : isReviewing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-white/80">Review recording</span>
|
||||
|
||||
Reference in New Issue
Block a user