Базовая версия голосовых сообщений и аудиоплеер. Кодирование OPUS
This commit is contained in:
@@ -9,6 +9,7 @@ import { AttachmentError } from "../AttachmentError/AttachmentError";
|
||||
import { MessageAvatar } from "./MessageAvatar";
|
||||
import { MessageProps } from "../Messages/Message";
|
||||
import { MessageCall } from "./MessageCall";
|
||||
import { MessageVoice } from "./MessageVoice";
|
||||
|
||||
export interface MessageAttachmentsProps {
|
||||
attachments: Attachment[];
|
||||
@@ -54,6 +55,8 @@ export function MessageAttachments(props: MessageAttachmentsProps) {
|
||||
return <MessageAvatar {...attachProps} key={index}></MessageAvatar>
|
||||
case AttachmentType.CALL:
|
||||
return <MessageCall {...attachProps} key={index}></MessageCall>
|
||||
case AttachmentType.VOICE:
|
||||
return <MessageVoice {...attachProps} key={index}></MessageVoice>
|
||||
default:
|
||||
return <AttachmentError key={index}></AttachmentError>;
|
||||
}
|
||||
|
||||
270
app/components/MessageAttachments/MessageVoice.tsx
Normal file
270
app/components/MessageAttachments/MessageVoice.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
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<HTMLDivElement | null>(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" });
|
||||
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<Flex gap="sm" align="center">
|
||||
<Avatar
|
||||
bg={error ? colors.error : colors.brandColor}
|
||||
size={40}
|
||||
style={{ cursor: "pointer", position: "relative" }}
|
||||
onClick={handleMainAction}
|
||||
>
|
||||
{!error && (
|
||||
<>
|
||||
{downloadStatus === DownloadStatus.DOWNLOADING &&
|
||||
downloadPercentage > 0 &&
|
||||
downloadPercentage < 100 && (
|
||||
<div style={{ position: "absolute", top: 0, left: 0 }}>
|
||||
<AnimatedRoundedProgress size={40} value={downloadPercentage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div style={{ position: "absolute", top: 0, left: 0 }}>
|
||||
<AnimatedRoundedProgress color="#fff" size={40} value={uploadedPercentage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{downloadStatus !== DownloadStatus.DOWNLOADED && <IconArrowDown color="white" size={22} />}
|
||||
|
||||
{downloadStatus === DownloadStatus.DOWNLOADED &&
|
||||
(isCurrentTrack && playing ? (
|
||||
<IconPlayerPauseFilled color="white" size={22} />
|
||||
) : (
|
||||
<IconPlayerPlayFilled color="white" size={22} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(error || isUploading) && <IconX color="white" size={22} />}
|
||||
</Avatar>
|
||||
|
||||
<Flex direction="column">
|
||||
<Box
|
||||
ref={waveformRef}
|
||||
w={waveformWidth}
|
||||
h={32}
|
||||
onClick={handleSeek}
|
||||
style={{ overflow: "hidden", cursor: "pointer" }}
|
||||
>
|
||||
<Flex h="100%" align="center" gap={BAR_GAP} wrap="nowrap">
|
||||
{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 (
|
||||
<Box
|
||||
key={index}
|
||||
w={BAR_WIDTH}
|
||||
h={height}
|
||||
style={{
|
||||
flex: `0 0 ${BAR_WIDTH}px`,
|
||||
borderRadius: 999,
|
||||
background,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Text size="xs" c="dimmed">
|
||||
{timeText}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user