diff --git a/js/desktop/src/features/compose/recording-overlay.tsx b/js/desktop/src/features/compose/recording-overlay.tsx index aa9f15b..597e2df 100644 --- a/js/desktop/src/features/compose/recording-overlay.tsx +++ b/js/desktop/src/features/compose/recording-overlay.tsx @@ -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 (
- {display} + + {display} +
); } @@ -137,14 +183,31 @@ export function RecordingOverlay({ const isLoading = isRecording && !mediaStream; const requestIntent = useComposeIntentStore((s) => s.request); + const { elapsed, isWarning } = useRecordingCountdown( + isRecording && !isLoading, + ); + return (
+ {/* Top progress bar: fills over the recording duration, red in warning */} + {isRecording && !isLoading && ( +
+
+
+ )} {/* Loading state */} {isLoading && (
@@ -185,7 +248,7 @@ export function RecordingOverlay({ {/* Top center: recording indicator */}
{isRecording && !isLoading ? ( - + ) : isReviewing ? (
Review recording diff --git a/js/desktop/src/lib/constants.ts b/js/desktop/src/lib/constants.ts index 52914b8..e888941 100644 --- a/js/desktop/src/lib/constants.ts +++ b/js/desktop/src/lib/constants.ts @@ -4,6 +4,12 @@ export const MAX_ATTACHMENT_SIZE_BYTES = 25 * 1024 * 1024; /** Maximum number of file attachments per particle. */ 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 PRIVACY_URL = 'https://flowylabs.ai/llink/privacy'; diff --git a/js/desktop/src/styles/globals.css b/js/desktop/src/styles/globals.css index b63b917..2f31bf9 100644 --- a/js/desktop/src/styles/globals.css +++ b/js/desktop/src/styles/globals.css @@ -186,3 +186,39 @@ .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; +}