diff --git a/src/components/nostr/kinds/VoiceNoteRenderer.tsx b/src/components/nostr/kinds/VoiceNoteRenderer.tsx index fa6654d..19eb903 100644 --- a/src/components/nostr/kinds/VoiceNoteRenderer.tsx +++ b/src/components/nostr/kinds/VoiceNoteRenderer.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { kinds } from "nostr-tools"; import { BaseEventContainer, type BaseEventProps } from "./BaseEventRenderer"; import { Mic, Play, Pause, Reply } from "lucide-react"; @@ -10,7 +10,9 @@ import { InlineReplySkeleton } from "@/components/ui/skeleton"; import { KindBadge } from "@/components/KindBadge"; import { Button } from "@/components/ui/button"; import { getEventDisplayTitle } from "@/lib/event-title"; +import { getVoiceNoteMetadata } from "@/lib/imeta"; import type { NostrEvent } from "@/types/nostr"; +import type { LucideIcon } from "lucide-react"; import { Tooltip, TooltipContent, @@ -24,38 +26,6 @@ import { cn } from "@/lib/utils"; const VOICE_MESSAGE_KIND = 1222; const VOICE_MESSAGE_COMMENT_KIND = 1244; -/** - * Parse voice note metadata from imeta tags (NIP-92) - * Returns waveform data and duration if present - */ -function parseVoiceNoteMetadata(event: NostrEvent): { - waveform?: number[]; - duration?: number; -} { - const result: { waveform?: number[]; duration?: number } = {}; - - for (const tag of event.tags) { - if (tag[0] !== "imeta") continue; - - for (let i = 1; i < tag.length; i++) { - const parts = tag[i].split(" "); - if (parts.length < 2) continue; - - const key = parts[0]; - const value = parts.slice(1).join(" "); - - if (key === "waveform") { - // Waveform is space-separated amplitude integers - result.waveform = value.split(" ").map((v) => parseInt(v, 10)); - } else if (key === "duration") { - result.duration = parseFloat(value); - } - } - } - - return result; -} - /** * Get audio URL from event content */ @@ -78,21 +48,32 @@ function formatDuration(seconds: number): string { } /** - * Waveform visualization component + * Safe Math.max for potentially large arrays + * Uses reduce to avoid stack overflow from spread operator + */ +function safeMax(arr: number[], defaultValue = 0): number { + if (arr.length === 0) return defaultValue; + return arr.reduce((max, val) => (val > max ? val : max), arr[0]); +} + +/** + * Waveform visualization component with accessibility support */ function WaveformVisualization({ waveform, progress, - onClick, + duration, + onSeek, }: { waveform: number[]; progress: number; // 0-1 - onClick?: (progress: number) => void; + duration: number; + onSeek?: (progress: number) => void; }) { const containerRef = useRef(null); - // Normalize waveform to 0-1 range - const maxAmplitude = Math.max(...waveform, 1); + // Normalize waveform to 0-1 range using safe max + const maxAmplitude = safeMax(waveform, 1); const normalizedWaveform = waveform.map((v) => v / maxAmplitude); // Limit to ~50 bars for display @@ -101,21 +82,47 @@ function WaveformVisualization({ const displayBars: number[] = []; for (let i = 0; i < waveform.length; i += step) { const chunk = normalizedWaveform.slice(i, i + step); - displayBars.push(Math.max(...chunk)); + displayBars.push(safeMax(chunk, 0)); } const handleClick = (e: React.MouseEvent) => { - if (!containerRef.current || !onClick) return; + if (!containerRef.current || !onSeek) return; const rect = containerRef.current.getBoundingClientRect(); const clickProgress = (e.clientX - rect.left) / rect.width; - onClick(Math.max(0, Math.min(1, clickProgress))); + onSeek(Math.max(0, Math.min(1, clickProgress))); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!onSeek) return; + const step = 0.05; // 5% step + if (e.key === "ArrowLeft") { + e.preventDefault(); + onSeek(Math.max(0, progress - step)); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + onSeek(Math.min(1, progress + step)); + } else if (e.key === "Home") { + e.preventDefault(); + onSeek(0); + } else if (e.key === "End") { + e.preventDefault(); + onSeek(1); + } }; return (
{displayBars.map((amplitude, i) => { const barProgress = i / displayBars.length; @@ -138,29 +145,57 @@ function WaveformVisualization({ } /** - * Simple progress bar fallback when no waveform is available + * Simple progress bar fallback with accessibility support */ function SimpleProgressBar({ progress, - onClick, + duration, + onSeek, }: { progress: number; - onClick?: (progress: number) => void; + duration: number; + onSeek?: (progress: number) => void; }) { const containerRef = useRef(null); const handleClick = (e: React.MouseEvent) => { - if (!containerRef.current || !onClick) return; + if (!containerRef.current || !onSeek) return; const rect = containerRef.current.getBoundingClientRect(); const clickProgress = (e.clientX - rect.left) / rect.width; - onClick(Math.max(0, Math.min(1, clickProgress))); + onSeek(Math.max(0, Math.min(1, clickProgress))); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!onSeek) return; + const step = 0.05; // 5% step + if (e.key === "ArrowLeft") { + e.preventDefault(); + onSeek(Math.max(0, progress - step)); + } else if (e.key === "ArrowRight") { + e.preventDefault(); + onSeek(Math.min(1, progress + step)); + } else if (e.key === "Home") { + e.preventDefault(); + onSeek(0); + } else if (e.key === "End") { + e.preventDefault(); + onSeek(1); + } }; return (
{ + setError(false); + setIsPlaying(false); + setCurrentTime(0); + if (initialDuration) { + setDuration(initialDuration); + } + }, [url, initialDuration]); + useEffect(() => { const audio = audioRef.current; if (!audio) return; @@ -215,6 +260,8 @@ function VoiceNotePlayer({ audio.addEventListener("pause", handlePause); return () => { + // Pause audio on unmount to prevent continued playback + audio.pause(); audio.removeEventListener("timeupdate", handleTimeUpdate); audio.removeEventListener("durationchange", handleDurationChange); audio.removeEventListener("loadedmetadata", handleDurationChange); @@ -223,7 +270,7 @@ function VoiceNotePlayer({ audio.removeEventListener("play", handlePlay); audio.removeEventListener("pause", handlePause); }; - }, []); + }, [url]); const togglePlayback = () => { const audio = audioRef.current; @@ -236,11 +283,15 @@ function VoiceNotePlayer({ } }; - const handleSeek = (progress: number) => { - const audio = audioRef.current; - if (!audio || !duration) return; - audio.currentTime = progress * duration; - }; + const handleSeek = useCallback( + (progress: number) => { + const audio = audioRef.current; + // Only seek if we have a valid positive duration + if (!audio || duration <= 0) return; + audio.currentTime = progress * duration; + }, + [duration] + ); const progress = duration > 0 ? currentTime / duration : 0; @@ -263,7 +314,12 @@ function VoiceNotePlayer({ return (
-