"use client"; import { ChevronDownIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, type PointerEvent, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; // Stays false until mounted so the server render and the first client render match. function useSafeReducedMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } export interface VoiceNoteWord { text: string; start: number; } export interface VoiceNoteSender { name: string; initials: string; avatarClassName?: string; } export interface VoiceNoteProps { sender?: VoiceNoteSender; sentAt?: string; duration?: number; src?: string; peaks?: number[]; transcript?: VoiceNoteWord[]; speeds?: number[]; defaultTranscriptOpen?: boolean; unplayed?: boolean; className?: string; } const defaultTranscript: VoiceNoteWord[] = (() => { const text = "Hey, quick one before standup. The venue moved us to the upstairs room, so the projector is sorted. Can you bring the clicker and two extension cords? I'll grab coffee for everyone on the way in. See you at ten!"; let t = 0.35; return text.split(" ").map((word) => { const start = t; t += 0.18 + word.length * 0.045 + (/[.,?!]$/.test(word) ? 0.32 : 0); return { text: word, start: Math.round(start * 100) / 100 }; }); })(); function seededPeaks(count: number, seed = 7) { let s = seed; const rand = () => { s = (s * 16807) % 2147483647; return s / 2147483647; }; return Array.from({ length: count }, (_, i) => { const phrase = 0.55 + 0.45 * Math.sin(i / 3.1) * Math.sin(i / 7.3 + 1); const v = 0.22 + Math.abs(phrase) * 0.6 + rand() * 0.28; return Math.min(1, Math.max(0.12, v)); }); } function formatTime(seconds: number) { const s = Math.max(0, Math.floor(seconds)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } // Both icons use the same point count so the paths morph cleanly. const ICON = { play: ["M8 5 L13.5 8.5 L13.5 15.5 L8 19 Z", "M13.5 8.5 L19 12 L19 12 L13.5 15.5 Z"], pause: ["M6.5 5 L10 5 L10 19 L6.5 19 Z", "M14 5 L17.5 5 L17.5 19 L14 19 Z"], }; function PlayPauseIcon({ playing, reduce }: { playing: boolean; reduce: boolean | null }) { const paths = playing ? ICON.pause : ICON.play; const transition = reduce ? { duration: 0 } : { duration: 0.18, ease: [0.23, 1, 0.32, 1] as const }; return ( ); } export function VoiceNote({ sender = { name: "Noor Haddad", initials: "NH", avatarClassName: "bg-violet-100 text-violet-700 dark:bg-violet-950 dark:text-violet-300", }, sentAt = "9:41", duration: durationProp, src, peaks: peaksProp, transcript = defaultTranscript, speeds = [1, 1.5, 2], defaultTranscriptOpen = true, unplayed = true, className, }: VoiceNoteProps) { const reduce = useSafeReducedMotion(); const transcriptId = useId(); const audioRef = useRef(null); const waveRef = useRef(null); const fallbackDuration = useMemo(() => { const last = transcript[transcript.length - 1]; return last ? Math.ceil(last.start + 1.2) : 20; }, [transcript]); const [mediaDuration, setMediaDuration] = useState(null); const duration = mediaDuration ?? durationProp ?? fallbackDuration; const peaks = useMemo(() => peaksProp ?? seededPeaks(44), [peaksProp]); const [time, setTime] = useState(0); const [playing, setPlaying] = useState(false); const [speedIndex, setSpeedIndex] = useState(0); const [scrubbing, setScrubbing] = useState(false); const [played, setPlayed] = useState(!unplayed); const [open, setOpen] = useState(defaultTranscriptOpen); const speed = speeds[speedIndex] ?? 1; const timeRef = useRef(0); timeRef.current = time; useEffect(() => { if (!playing || scrubbing) return; const audio = audioRef.current; let frame = 0; let last = performance.now(); const tick = (now: number) => { const dt = (now - last) / 1000; last = now; const next = audio && src ? audio.currentTime : timeRef.current + dt * speed; if (next >= duration) { setTime(0); setPlaying(false); if (audio && src) { audio.pause(); audio.currentTime = 0; } return; } setTime(next); frame = requestAnimationFrame(tick); }; frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); }, [playing, scrubbing, speed, duration, src]); useEffect(() => { const audio = audioRef.current; if (!audio || !src) return; audio.playbackRate = speed; }, [speed, src]); const toggle = useCallback(() => { const audio = audioRef.current; setPlayed(true); setPlaying((p) => { if (audio && src) { if (p) audio.pause(); else void audio.play().catch(() => setPlaying(false)); } return !p; }); }, [src]); const seek = useCallback( (to: number) => { const t = Math.min(duration - 0.05, Math.max(0, to)); setTime(t); setPlayed(true); const audio = audioRef.current; if (audio && src) audio.currentTime = t; }, [duration, src], ); const timeFromPointer = (clientX: number) => { const rect = waveRef.current?.getBoundingClientRect(); if (!rect) return 0; return ((clientX - rect.left) / rect.width) * duration; }; const onPointerDown = (e: PointerEvent) => { if (e.button !== 0) return; e.currentTarget.setPointerCapture(e.pointerId); setScrubbing(true); seek(timeFromPointer(e.clientX)); }; const onPointerMove = (e: PointerEvent) => { if (scrubbing) seek(timeFromPointer(e.clientX)); }; const endScrub = () => setScrubbing(false); const onSliderKey = (e: KeyboardEvent) => { const step = e.shiftKey ? 5 : 1; if (e.key === "ArrowRight" || e.key === "ArrowUp") seek(time + step); else if (e.key === "ArrowLeft" || e.key === "ArrowDown") seek(time - step); else if (e.key === "Home") seek(0); else if (e.key === "End") seek(duration); else if (e.key === " " || e.key === "Enter") toggle(); else return; e.preventDefault(); }; const progress = Math.min(1, time / duration); const showHead = scrubbing || time > 0; let activeWord = -1; if (time > 0 || playing) { for (let i = 0; i < transcript.length; i++) { if (transcript[i].start <= time) activeWord = i; else break; } } return (
{sender.initials}
{sender.name} {sentAt}
{src && (
); } function Bars({ peaks, className }: { peaks: number[]; className?: string }) { return (
{peaks.map((p, i) => ( ))}
); } function Transcript({ words, activeWord, onSeek, reduce, instant, }: { words: VoiceNoteWord[]; activeWord: number; instant?: boolean; onSeek: (t: number) => void; reduce: boolean | null; }) { const wrapRef = useRef(null); const [box, setBox] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const lastY = useRef(null); const [jump, setJump] = useState(false); useLayoutEffect(() => { const el = wrapRef.current?.querySelector(`[data-word="${activeWord}"]`); if (!el) { setBox(null); lastY.current = null; return; } const next = { x: el.offsetLeft - 3, y: el.offsetTop, w: el.offsetWidth + 6, h: el.offsetHeight }; setJump(lastY.current === null || lastY.current !== next.y); lastY.current = next.y; setBox(next); }, [activeWord]); return (

{box && ( )} {words.map((w, i) => ( onSeek(w.start + 0.01)} > {w.text} {" "} ))}

); }