import { DownloadStatus, useAttachment } from "@/app/providers/AttachmentProvider/useAttachment"; import { AttachmentProps } from "./MessageAttachments"; import { Avatar, Box, Flex, Text, useMantineTheme } from "@mantine/core"; import { AnimatedRoundedProgress } from "../AnimatedRoundedProgress/AnimatedRoundedProgress"; import { IconArrowDown, IconPlayerPauseFilled, IconPlayerPlayFilled, IconX } from "@tabler/icons-react"; import { DeliveredMessageState } from "@/app/providers/DialogProvider/DialogProvider"; import { useRosettaColors } from "@/app/hooks/useRosettaColors"; import { useMemo, useRef } from "react"; import { usePlayerContext } from "@/app/providers/PlayerProvider/usePlayerContext"; const WAVE_BARS = 40; const BAR_WIDTH = 2; const BAR_GAP = 2; const MIN_BAR_HEIGHT = 4; const MAX_BAR_HEIGHT = 24; function normalizeWaves(source: number[], targetLength: number): number[] { if (targetLength <= 0) return []; if (source.length === 0) return Array(targetLength).fill(0); if (source.length === targetLength) return source; if (source.length > targetLength) { const compressed: number[] = []; const bucketSize = source.length / targetLength; for (let i = 0; i < targetLength; i++) { const start = Math.floor(i * bucketSize); const end = Math.max(start + 1, Math.floor((i + 1) * bucketSize)); let max = 0; for (let j = start; j < end && j < source.length; j++) { if (source[j] > max) max = source[j]; } compressed.push(max); } return compressed; } if (targetLength === 1) return [source[0]]; const stretched: number[] = []; const lastSourceIndex = source.length - 1; for (let i = 0; i < targetLength; i++) { const position = (i * lastSourceIndex) / (targetLength - 1); const left = Math.floor(position); const right = Math.min(Math.ceil(position), lastSourceIndex); if (left === right) { stretched.push(source[left]); continue; } const t = position - left; stretched.push(source[left] * (1 - t) + source[right] * t); } return stretched; } function formatTime(seconds: number) { const s = Math.max(0, Math.floor(seconds)); const m = Math.floor(s / 60).toString().padStart(2, "0"); const r = (s % 60).toString().padStart(2, "0"); return `${m}:${r}`; } export function MessageVoice(props: AttachmentProps) { const { downloadPercentage, downloadStatus, uploadedPercentage, download, getPreview } = useAttachment( props.attachment, props.parent, ); const theme = useMantineTheme(); const colors = useRosettaColors(); const preview = getPreview() || ""; const [durationPart = "0", wavesPart = ""] = preview.split("::"); const previewDuration = Number.parseInt(durationPart, 10) || 0; const rawWaves = useMemo( () => wavesPart .split(",") .map((s) => Number.parseFloat(s)) .filter((n) => Number.isFinite(n) && n >= 0), [wavesPart] ); const waves = useMemo(() => normalizeWaves(rawWaves, WAVE_BARS), [rawWaves]); const peak = useMemo(() => { const max = Math.max(...waves, 0); return max > 0 ? max : 1; }, [waves]); const isUploading = props.delivered === DeliveredMessageState.WAITING && uploadedPercentage > 0 && uploadedPercentage < 100; const error = downloadStatus === DownloadStatus.ERROR; const waveformWidth = WAVE_BARS * BAR_WIDTH + (WAVE_BARS - 1) * BAR_GAP; const waveformRef = useRef(null); const { playAudio, pause, duration: currentDuration, playing, setDuration, totalDuration, currentMessageId, } = usePlayerContext(); const messageId = String((props.parent as any)?.id ?? (props.attachment as any)?.messageId ?? props.attachment.id); const isCurrentTrack = currentMessageId === messageId; const fullDuration = Math.max(isCurrentTrack && totalDuration > 0 ? totalDuration : previewDuration, 1); const safeCurrent = isCurrentTrack ? currentDuration : 0; const playbackProgress = Math.max(0, Math.min(1, safeCurrent / fullDuration)); const createAudioBlob = () => new Blob([Buffer.from(props.attachment.blob, "binary")], { type: "audio/webm;codecs=opus" }); const ensureStarted = (seekToSec?: number) => { const blob = createAudioBlob(); playAudio("Voice Message", "", blob, messageId); if (typeof seekToSec === "number") { requestAnimationFrame(() => setDuration(seekToSec)); } }; const handleMainAction = () => { if (error) return; if (downloadStatus !== DownloadStatus.DOWNLOADED) { download(); return; } if (!isCurrentTrack) { ensureStarted(); return; } if (playing) { pause(); return; } ensureStarted(Math.max(0, safeCurrent)); }; const handleSeek = (e: React.MouseEvent) => { if (error || downloadStatus !== DownloadStatus.DOWNLOADED) return; const rect = waveformRef.current?.getBoundingClientRect(); if (!rect || rect.width <= 0) return; const x = e.clientX - rect.left; const progress = Math.max(0, Math.min(1, x / rect.width)); const seekTo = progress * fullDuration; if (!isCurrentTrack) { ensureStarted(seekTo); return; } setDuration(seekTo); }; const timeText = isCurrentTrack && safeCurrent > 0 ? `-${formatTime(Math.max(0, fullDuration - safeCurrent))}` : formatTime(fullDuration); return ( {!error && ( <> {downloadStatus === DownloadStatus.DOWNLOADING && downloadPercentage > 0 && downloadPercentage < 100 && (
)} {isUploading && (
)} {downloadStatus !== DownloadStatus.DOWNLOADED && } {downloadStatus === DownloadStatus.DOWNLOADED && !isUploading && (isCurrentTrack && playing ? ( ) : ( ))} )} {(error || isUploading) && }
{waves.map((value, index) => { const normalized = Math.max(0, Math.min(1, value / peak)); const height = Math.max( MIN_BAR_HEIGHT, Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT)) ); const passed = playbackProgress * waves.length - index; const fillPercent = Math.max(0, Math.min(1, passed)); const inactiveColor = theme.colors.gray[4]; const activeColor = colors.brandColor; let background = inactiveColor; if (fillPercent >= 1) { background = activeColor; } else if (fillPercent > 0) { background = `linear-gradient(90deg, ${activeColor} 0%, ${activeColor} ${fillPercent * 100}%, ${inactiveColor} ${fillPercent * 100}%, ${inactiveColor} 100%)`; } return ( ); })} {timeText}
); }