268 lines
9.7 KiB
TypeScript
268 lines
9.7 KiB
TypeScript
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;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<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 && !isUploading &&
|
|
(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>
|
|
);
|
|
} |