278 lines
7.7 KiB
TypeScript
278 lines
7.7 KiB
TypeScript
import { Box, Flex, Text, useMantineTheme } from "@mantine/core";
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
interface VoiceRecorderProps {
|
|
duration: number;
|
|
waves: number[];
|
|
}
|
|
|
|
type AnimatedBar = {
|
|
id: number;
|
|
value: number;
|
|
entered: boolean;
|
|
};
|
|
|
|
const VISIBLE_BARS = 50;
|
|
const BAR_WIDTH = 3;
|
|
const BAR_GAP = 2;
|
|
const STEP_PX = BAR_WIDTH + BAR_GAP;
|
|
const COMPONENT_HEIGHT = 45;
|
|
const MAX_BAR_HEIGHT = 28;
|
|
const MIN_BAR_HEIGHT = 4;
|
|
|
|
export function VoiceRecorder(props: VoiceRecorderProps) {
|
|
const theme = useMantineTheme();
|
|
const [bars, setBars] = useState<AnimatedBar[]>([]);
|
|
const [subShift, setSubShift] = useState(0);
|
|
|
|
const prevLengthRef = useRef(0);
|
|
const prevWavesRef = useRef<number[]>([]);
|
|
const idRef = useRef(0);
|
|
const enterFrameRef = useRef<number | null>(null);
|
|
const scrollFrameRef = useRef<number | null>(null);
|
|
|
|
const lastAppendAtRef = useRef<number | null>(null);
|
|
const appendIntervalRef = useRef(120);
|
|
const barsLengthRef = useRef(0);
|
|
|
|
const formatDuration = (seconds: number) => {
|
|
const mins = Math.floor(seconds / 60)
|
|
.toString()
|
|
.padStart(2, "0");
|
|
const secs = (seconds % 60).toString().padStart(2, "0");
|
|
return `${mins}:${secs}`;
|
|
};
|
|
|
|
useEffect(() => {
|
|
barsLengthRef.current = bars.length;
|
|
}, [bars.length]);
|
|
|
|
useEffect(() => {
|
|
if (props.waves.length === 0) {
|
|
setBars([]);
|
|
setSubShift(0);
|
|
prevLengthRef.current = 0;
|
|
prevWavesRef.current = [];
|
|
lastAppendAtRef.current = null;
|
|
return;
|
|
}
|
|
|
|
if (props.waves.length < prevLengthRef.current) {
|
|
const resetBars = props.waves.slice(-VISIBLE_BARS).map((value) => ({
|
|
id: idRef.current++,
|
|
value,
|
|
entered: true,
|
|
}));
|
|
|
|
setBars(resetBars);
|
|
setSubShift(0);
|
|
prevLengthRef.current = props.waves.length;
|
|
prevWavesRef.current = props.waves;
|
|
lastAppendAtRef.current = performance.now();
|
|
return;
|
|
}
|
|
|
|
const prevWaves = prevWavesRef.current;
|
|
let appended: number[] = [];
|
|
|
|
// Обычный режим: длина выросла
|
|
if (props.waves.length > prevLengthRef.current) {
|
|
appended = props.waves.slice(prevLengthRef.current);
|
|
} else if (props.waves.length === prevLengthRef.current && props.waves.length > 0) {
|
|
// Rolling buffer: длина та же, но данные сдвигаются
|
|
let changed = false;
|
|
|
|
if (prevWaves.length !== props.waves.length) {
|
|
changed = true;
|
|
} else {
|
|
for (let i = 0; i < props.waves.length; i++) {
|
|
if (props.waves[i] !== prevWaves[i]) {
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
appended = [props.waves[props.waves.length - 1]];
|
|
}
|
|
}
|
|
|
|
if (appended.length > 0) {
|
|
const now = performance.now();
|
|
|
|
if (lastAppendAtRef.current != null) {
|
|
const dt = now - lastAppendAtRef.current;
|
|
const perBar = dt / appended.length;
|
|
appendIntervalRef.current = appendIntervalRef.current * 0.7 + perBar * 0.3;
|
|
}
|
|
|
|
lastAppendAtRef.current = now;
|
|
setSubShift(0);
|
|
|
|
const newIds: number[] = [];
|
|
|
|
setBars((prev) => {
|
|
const next = [...prev];
|
|
|
|
appended.forEach((value) => {
|
|
const id = idRef.current++;
|
|
newIds.push(id);
|
|
|
|
next.push({
|
|
id,
|
|
value,
|
|
entered: false,
|
|
});
|
|
});
|
|
|
|
return next.slice(-VISIBLE_BARS);
|
|
});
|
|
|
|
if (enterFrameRef.current) {
|
|
cancelAnimationFrame(enterFrameRef.current);
|
|
}
|
|
|
|
enterFrameRef.current = requestAnimationFrame(() => {
|
|
setBars((prev) => {
|
|
const ids = new Set(newIds);
|
|
return prev.map((bar) => (ids.has(bar.id) ? { ...bar, entered: true } : bar));
|
|
});
|
|
});
|
|
}
|
|
|
|
prevLengthRef.current = props.waves.length;
|
|
prevWavesRef.current = props.waves;
|
|
}, [props.waves]);
|
|
|
|
useEffect(() => {
|
|
const tick = () => {
|
|
const startedAt = lastAppendAtRef.current;
|
|
|
|
if (startedAt != null) {
|
|
const elapsed = performance.now() - startedAt;
|
|
const interval = Math.max(16, appendIntervalRef.current);
|
|
const progress = Math.min(1, elapsed / interval);
|
|
|
|
const smoothShift = barsLengthRef.current >= VISIBLE_BARS ? -progress * STEP_PX : 0;
|
|
setSubShift(smoothShift);
|
|
} else {
|
|
setSubShift(0);
|
|
}
|
|
|
|
scrollFrameRef.current = requestAnimationFrame(tick);
|
|
};
|
|
|
|
scrollFrameRef.current = requestAnimationFrame(tick);
|
|
|
|
return () => {
|
|
if (scrollFrameRef.current) {
|
|
cancelAnimationFrame(scrollFrameRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (enterFrameRef.current) {
|
|
cancelAnimationFrame(enterFrameRef.current);
|
|
}
|
|
if (scrollFrameRef.current) {
|
|
cancelAnimationFrame(scrollFrameRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const waveformWidth = VISIBLE_BARS * BAR_WIDTH + (VISIBLE_BARS - 1) * BAR_GAP;
|
|
|
|
return (
|
|
<Flex
|
|
direction="row"
|
|
h={COMPONENT_HEIGHT}
|
|
mih={COMPONENT_HEIGHT}
|
|
mah={COMPONENT_HEIGHT}
|
|
align="center"
|
|
justify="center"
|
|
gap="xs"
|
|
px={6}
|
|
>
|
|
<Text size="xs" c="dimmed" w={36}>
|
|
{formatDuration(props.duration)}
|
|
</Text>
|
|
|
|
<Box
|
|
w={waveformWidth}
|
|
h={COMPONENT_HEIGHT}
|
|
style={{
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<Flex
|
|
h="100%"
|
|
align="center"
|
|
gap={BAR_GAP}
|
|
wrap="nowrap"
|
|
style={{ transform: `translateX(${subShift}px)` }}
|
|
>
|
|
{Array.from({ length: VISIBLE_BARS }).map((_, index) => {
|
|
const bar = bars[index];
|
|
|
|
if (!bar) {
|
|
return (
|
|
<Box
|
|
key={`empty-${index}`}
|
|
w={BAR_WIDTH}
|
|
h={MIN_BAR_HEIGHT}
|
|
style={{
|
|
flex: `0 0 ${BAR_WIDTH}px`,
|
|
borderRadius: 999,
|
|
background: theme.colors.gray[3],
|
|
opacity: 0.22,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const normalized = Math.max(0, Math.min(1, bar.value));
|
|
const height = Math.max(
|
|
MIN_BAR_HEIGHT,
|
|
Math.min(MAX_BAR_HEIGHT, MIN_BAR_HEIGHT + normalized * (MAX_BAR_HEIGHT - MIN_BAR_HEIGHT))
|
|
);
|
|
const isLast = index === bars.length - 1;
|
|
const isNearTail = index >= bars.length - 3;
|
|
|
|
return (
|
|
<Box
|
|
key={bar.id}
|
|
w={BAR_WIDTH}
|
|
h={height}
|
|
style={{
|
|
flex: `0 0 ${BAR_WIDTH}px`,
|
|
alignSelf: "center",
|
|
borderRadius: 999,
|
|
background: isLast
|
|
? `linear-gradient(180deg, ${theme.colors.blue[3]} 0%, ${theme.colors.blue[5]} 100%)`
|
|
: `linear-gradient(180deg, ${theme.colors.blue[4]} 0%, ${theme.colors.blue[6]} 100%)`,
|
|
boxShadow: isLast
|
|
? `0 0 10px ${theme.colors.blue[4]}55`
|
|
: isNearTail
|
|
? `0 0 6px ${theme.colors.blue[4]}22`
|
|
: "none",
|
|
transform: bar.entered ? "scaleY(1)" : "scaleY(0.18)",
|
|
transformOrigin: "center center",
|
|
transition: [
|
|
"height 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
|
|
"transform 260ms cubic-bezier(0.2, 0.8, 0.2, 1)",
|
|
"opacity 220ms ease",
|
|
"box-shadow 220ms ease",
|
|
].join(", "),
|
|
willChange: "height, transform, opacity",
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</Flex>
|
|
</Box>
|
|
</Flex>
|
|
);
|
|
} |