From b596d36543b1e660cab93af4a059c30aded67820 Mon Sep 17 00:00:00 2001 From: RoyceDa Date: Fri, 10 Apr 2026 17:20:44 +0200 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=B0=D0=B7=D0=BE=D0=B2=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20=D0=B3=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D1=8B=D1=85=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B8=20=D0=B0=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D0=BE=D0=BF=D0=BB=D0=B5=D0=B5=D1=80.=20=D0=9A=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20OPUS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/App.tsx | 29 +- app/components/DialogInput/DialogInput.tsx | 173 ++++++----- app/components/DialogInput/useVoiceMessage.ts | 273 +++++++++++++++++ .../MessageAttachments/MessageAttachments.tsx | 3 + .../MessageAttachments/MessageVoice.tsx | 270 +++++++++++++++++ .../VoiceRecorder/VoiceRecorder.tsx | 278 ++++++++++++++++++ app/providers/DialogProvider/useDialog.ts | 1 + .../PlayerProvider/PlayerProvider.tsx | 258 +++++++++++++++- .../PlayerProvider/usePlayerContext.ts | 10 + .../protocol/packets/packet.message.ts | 3 +- .../constructLastMessageTextByAttachments.ts | 2 + 11 files changed, 1204 insertions(+), 96 deletions(-) create mode 100644 app/components/DialogInput/useVoiceMessage.ts create mode 100644 app/components/MessageAttachments/MessageVoice.tsx create mode 100644 app/components/VoiceRecorder/VoiceRecorder.tsx create mode 100644 app/providers/PlayerProvider/usePlayerContext.ts diff --git a/app/App.tsx b/app/App.tsx index 5d0d7cf..deeda47 100644 --- a/app/App.tsx +++ b/app/App.tsx @@ -22,6 +22,7 @@ import { DialogStateProvider } from './providers/DialogStateProvider.tsx/DialogS import { DeviceConfirm } from './views/DeviceConfirm/DeviceConfirm'; import { SystemAccountProvider } from './providers/SystemAccountsProvider/SystemAccountsProvider'; import { DeviceProvider } from './providers/DeviceProvider/DeviceProvider'; +import { PlayerProvider } from './providers/PlayerProvider/PlayerProvider'; window.Buffer = Buffer; export default function App() { @@ -58,19 +59,21 @@ export default function App() { - - - - } /> - } /> - } /> - } /> - } /> - } /> - - + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + diff --git a/app/components/DialogInput/DialogInput.tsx b/app/components/DialogInput/DialogInput.tsx index 6a28fbb..9734052 100644 --- a/app/components/DialogInput/DialogInput.tsx +++ b/app/components/DialogInput/DialogInput.tsx @@ -1,7 +1,7 @@ import { useDialog } from "@/app/providers/DialogProvider/useDialog"; import { useRosettaColors } from "@/app/hooks/useRosettaColors"; import { Box, Divider, Flex, Menu, Popover, Text, Transition, useComputedColorScheme } from "@mantine/core"; -import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMicrophone, IconMoodSmile, IconPaperclip, IconSend } from "@tabler/icons-react"; +import { IconBarrierBlock, IconCamera, IconDoorExit, IconFile, IconMicrophone, IconMoodSmile, IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react"; import { useEffect, useRef, useState } from "react"; import { useBlacklist } from "@/app/providers/BlacklistProvider/useBlacklist"; import { filePrapareForNetworkTransfer, generateRandomKey, imagePrepareForNetworkTransfer } from "@/app/utils/utils"; @@ -25,7 +25,8 @@ import { AnimatedButton } from "../AnimatedButton/AnimatedButton"; import { useUserCacheFunc } from "@/app/providers/InformationProvider/useUserCacheFunc"; import { MentionList, Mention } from "../MentionList/MentionList"; import { useDrafts } from "@/app/providers/DialogProvider/useDrafts"; - +import { useVoiceMessage } from "./useVoiceMessage"; +import { VoiceRecorder } from "../VoiceRecorder/VoiceRecorder"; export function DialogInput() { const colors = useRosettaColors(); @@ -47,6 +48,7 @@ export function DialogInput() { const [mentionList, setMentionList] = useState([]); const mentionHandling = useRef(""); const {getDraft, saveDraft} = useDrafts(dialog); + const {start, stop, isRecording, duration, waves, getAudioBlob, interpolateCompressWaves} = useVoiceMessage(); const avatars = useAvatars( @@ -65,10 +67,12 @@ export function DialogInput() { ], [], true); const hasText = message.trim().length > 0; - const showSendIcon = hasText || attachments.length > 0; + const showSendIcon = hasText || attachments.length > 0 || isRecording; - const onMicClick = () => { - console.info("Start voice record"); + const onMicroClick = () => { + if(!isRecording) { + start(); + } }; const fileDialog = useFileDialog({ @@ -195,8 +199,28 @@ export function DialogInput() { mentionHandling.current = username; } - const send = () => { - if(blocked || (message.trim() == "" && attachments.length <= 0)) { + const send = async () => { + if(blocked || (message.trim() == "" && attachments.length <= 0 && !isRecording)){ + return; + } + if(isRecording){ + const audioBlob = getAudioBlob(); + stop(); + if(!audioBlob){ + return; + } + sendMessage("", [ + { + blob: Buffer.from(await audioBlob.arrayBuffer()).toString('binary'), + id: generateRandomKey(8), + type: AttachmentType.VOICE, + preview: duration + "::" + interpolateCompressWaves(35).join(","), + transport: { + transport_server: "", + transport_tag: "" + } + } + ]); return; } sendMessage(message, attachments); @@ -372,77 +396,84 @@ export function DialogInput() { {!blocked && - - - - - - Attach - - } onClick={onClickPaperclip}>File - {((avatars.length > 0 && !hasGroup(dialog)) - || (avatars.length > 0 && hasGroup(dialog) && isAdmin)) - && - } onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}} - - + {isRecording && ( + + )} + {!isRecording && ( + + + + + + Attach + + } onClick={onClickPaperclip}>File + {((avatars.length > 0 && !hasGroup(dialog)) + || (avatars.length > 0 && hasGroup(dialog) && isAdmin)) + && + } onClick={onClickCamera}>Avatar {hasGroup(dialog) && 'group'}} + + + )} - + {!isRecording && <> + + } + {isRecording && <> + + } - - - - - - - - - + {!isRecording && <> + + + + + + + + + } + {(styles) => ( Promise; + stop: () => void; + pause: () => void; + play: () => void; + error: string | null; + getAudioBlob: () => Blob | null; + interpolateCompressWaves: (targetLength: number) => number[]; +} { + const [isRecording, setIsRecording] = useState(false); + const [isPaused, setIsPaused] = useState(false); + const [duration, setDuration] = useState(0); + const [waves, setWaves] = useState([]); + const [error, setError] = useState(null); + + const mediaRecorderRef = useRef(null); + const chunksRef = useRef([]); + const streamRef = useRef(null); + const timerRef = useRef | null>(null); + const waveTimerRef = useRef | null>(null); + + const audioContextRef = useRef(null); + const analyserRef = useRef(null); + const sourceRef = useRef(null); + const waveDataRef = useRef | null>(null); + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const stopWaveLoop = useCallback(() => { + if (waveTimerRef.current) { + clearInterval(waveTimerRef.current); + waveTimerRef.current = null; + } + }, []); + + const startTimer = useCallback(() => { + if (timerRef.current) return; + timerRef.current = setInterval(() => { + setDuration((prev) => prev + 1); + }, 1000); + }, []); + + const startWaveLoop = useCallback(() => { + stopWaveLoop(); + + const analyser = analyserRef.current; + if (!analyser) return; + + if (!waveDataRef.current || waveDataRef.current.length !== analyser.frequencyBinCount) { + waveDataRef.current = new Uint8Array(new ArrayBuffer(analyser.frequencyBinCount)); + } + + const MAX_WAVES = 120; + + const tick = () => { + if (!analyserRef.current || !waveDataRef.current) return; + + analyserRef.current.getByteFrequencyData(waveDataRef.current); + + let peak = 0; + for (let i = 0; i < waveDataRef.current.length; i++) { + const v = waveDataRef.current[i]; + if (v > peak) peak = v; + } + + const bar = peak / 255; + + setWaves((prev) => { + const next = [...prev, bar]; + return next.length > MAX_WAVES ? next.slice(next.length - MAX_WAVES) : next; + }); + }; + + tick(); + waveTimerRef.current = setInterval(tick, 300); + }, [stopWaveLoop]); + + const cleanupAudio = useCallback(() => { + stopWaveLoop(); + + sourceRef.current?.disconnect(); + sourceRef.current = null; + analyserRef.current = null; + waveDataRef.current = null; + + if (audioContextRef.current) { + audioContextRef.current.close(); + audioContextRef.current = null; + } + + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + }, [stopWaveLoop]); + + const start = useCallback(async () => { + try { + setError(null); + setDuration(0); + setWaves([]); + chunksRef.current = []; + + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + streamRef.current = stream; + + const audioContext = new AudioContext(); + const analyser = audioContext.createAnalyser(); + analyser.fftSize = 2048; + analyser.smoothingTimeConstant = 0; + analyser.minDecibels = -100; + analyser.maxDecibels = -10; + + const source = audioContext.createMediaStreamSource(stream); + source.connect(analyser); + + audioContextRef.current = audioContext; + analyserRef.current = analyser; + sourceRef.current = source; + + // Выбираем лучший поддерживаемый кодек + const preferredTypes = [ + "audio/webm;codecs=opus", + "audio/ogg;codecs=opus", + "audio/webm", + ]; + + const mimeType = preferredTypes.find((t) => MediaRecorder.isTypeSupported(t)) ?? ""; + + const mediaRecorder = new MediaRecorder(stream, { + ...(mimeType ? { mimeType } : {}), + audioBitsPerSecond: 32_000, + }); + + mediaRecorderRef.current = mediaRecorder; + + mediaRecorder.ondataavailable = (e) => { + if (e.data.size > 0) chunksRef.current.push(e.data); + }; + + mediaRecorder.onstop = () => { + cleanupAudio(); + }; + + mediaRecorder.start(100); + setIsRecording(true); + setIsPaused(false); + startTimer(); + startWaveLoop(); + } catch (err) { + setError("Could not start voice recording. Please check microphone permissions."); + console.error("Voice recording error:", err); + } + }, [startTimer, startWaveLoop, cleanupAudio]); + + const stop = useCallback(() => { + if (mediaRecorderRef.current && isRecording) { + mediaRecorderRef.current.stop(); + mediaRecorderRef.current = null; + setIsRecording(false); + setIsPaused(false); + clearTimer(); + stopWaveLoop(); + } + }, [isRecording, clearTimer, stopWaveLoop]); + + const pause = useCallback(() => { + if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") { + mediaRecorderRef.current.pause(); + setIsPaused(true); + clearTimer(); + stopWaveLoop(); + } + }, [clearTimer, stopWaveLoop]); + + const play = useCallback(() => { + if (mediaRecorderRef.current && mediaRecorderRef.current.state === "paused") { + mediaRecorderRef.current.resume(); + setIsPaused(false); + startTimer(); + startWaveLoop(); + } + }, [startTimer, startWaveLoop]); + + useEffect(() => { + return () => { + clearTimer(); + stopWaveLoop(); + + if (mediaRecorderRef.current && mediaRecorderRef.current?.state !== "inactive") { + mediaRecorderRef.current.stop(); + } + + cleanupAudio(); + }; + }, [clearTimer, stopWaveLoop, cleanupAudio]); + + const getAudioBlob = useCallback((): Blob | null => { + if (chunksRef.current.length === 0) return null; + const mimeType = mediaRecorderRef.current?.mimeType ?? "audio/webm;codecs=opus"; + return new Blob(chunksRef.current, { type: mimeType }); + }, []); + + const interpolateCompressWaves = useCallback((targetLength: number) => { + if (targetLength <= 0) return []; + if (waves.length === 0) return Array(targetLength).fill(0); + if (waves.length === targetLength) return waves; + + if (waves.length > targetLength) { + const compressed: number[] = []; + const bucketSize = waves.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 < waves.length; j++) { + if (waves[j] > max) max = waves[j]; + } + + compressed.push(max); + } + + return compressed; + } + + if (targetLength === 1) return [waves[0]]; + + const stretched: number[] = []; + const lastSourceIndex = waves.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(waves[left]); + continue; + } + + const t = position - left; + const value = waves[left] * (1 - t) + waves[right] * t; + stretched.push(value); + } + + return stretched; + }, [waves]); + + return { + isRecording, + isPaused, + duration, + waves, + start, + stop, + pause, + play, + error, + getAudioBlob, + interpolateCompressWaves, + }; +} \ No newline at end of file diff --git a/app/components/MessageAttachments/MessageAttachments.tsx b/app/components/MessageAttachments/MessageAttachments.tsx index 2cef177..8e408dc 100644 --- a/app/components/MessageAttachments/MessageAttachments.tsx +++ b/app/components/MessageAttachments/MessageAttachments.tsx @@ -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 case AttachmentType.CALL: return + case AttachmentType.VOICE: + return default: return ; } diff --git a/app/components/MessageAttachments/MessageVoice.tsx b/app/components/MessageAttachments/MessageVoice.tsx new file mode 100644 index 0000000..ce04dda --- /dev/null +++ b/app/components/MessageAttachments/MessageVoice.tsx @@ -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(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) => { + 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 && + (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} + + +
+ ); +} \ No newline at end of file diff --git a/app/components/VoiceRecorder/VoiceRecorder.tsx b/app/components/VoiceRecorder/VoiceRecorder.tsx new file mode 100644 index 0000000..7fe9f11 --- /dev/null +++ b/app/components/VoiceRecorder/VoiceRecorder.tsx @@ -0,0 +1,278 @@ +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([]); + const [subShift, setSubShift] = useState(0); + + const prevLengthRef = useRef(0); + const prevWavesRef = useRef([]); + const idRef = useRef(0); + const enterFrameRef = useRef(null); + const scrollFrameRef = useRef(null); + + const lastAppendAtRef = useRef(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 ( + + + {formatDuration(props.duration)} + + + + + {Array.from({ length: VISIBLE_BARS }).map((_, index) => { + const bar = bars[index]; + + if (!bar) { + return ( + + ); + } + + 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 ( + + ); + })} + + + + ); +} \ No newline at end of file diff --git a/app/providers/DialogProvider/useDialog.ts b/app/providers/DialogProvider/useDialog.ts index 1a318aa..b5040df 100644 --- a/app/providers/DialogProvider/useDialog.ts +++ b/app/providers/DialogProvider/useDialog.ts @@ -146,6 +146,7 @@ export function useDialog() : { } console.info("Sending key for message ", key.toString('hex')); + console.info(attachemnts); let preparedToNetworkSendAttachements : Attachment[] = await prepareAttachmentsToSend(messageId, dialog, key.toString('hex'), attachemnts); if(attachemnts.length <= 0 && message.trim() == ""){ runQuery("UPDATE messages SET delivered = ? WHERE message_id = ?", [DeliveredMessageState.ERROR, messageId]); diff --git a/app/providers/PlayerProvider/PlayerProvider.tsx b/app/providers/PlayerProvider/PlayerProvider.tsx index 203bb48..6a62109 100644 --- a/app/providers/PlayerProvider/PlayerProvider.tsx +++ b/app/providers/PlayerProvider/PlayerProvider.tsx @@ -1,25 +1,261 @@ -import { createContext, useRef } from "react"; +import { createContext, useEffect, useRef, useState } from "react"; + +export interface PlayerContextValue { + playAudio: ( + artist: string, + title: string, + audio: string | Blob | File, + messageId?: string | null + ) => void; + playing: boolean; + pause: () => void; + resume: () => void; + stop: () => void; + setDuration: (duration: number) => void; + duration: number; + totalDuration: number; + currentMessageId: string | null; + lastMessageId: string | null; +} + +export const PlayerContext = createContext(null); -const PlayerContext = createContext(null); -/** - * Провайдер для Audio/Video плеера - */ interface PlayerProviderProps { children: React.ReactNode; } -export function PlayerProvider(props : PlayerProviderProps) { +export function PlayerProvider(props: PlayerProviderProps) { const audioRef = useRef(null); - const playVoice = () => { - - } + const objectUrlRef = useRef(null); + const rafTimeUpdateRef = useRef(null); + const isLoadingRef = useRef(false); + const isSeekingRef = useRef(false); + + const durationRef = useRef(0); + const totalDurationRef = useRef(0); + + const isPlayingRef = useRef(false); + const [isPlaying, setIsPlaying] = useState(false); + const [duration, setDurationState] = useState(0); + const [totalDuration, setTotalDuration] = useState(0); + + const [currentMessageId, setCurrentMessageId] = useState(null); + const [lastMessageId, setLastMessageId] = useState(null); + + const commitPlaying = (next: boolean) => { + if (isPlayingRef.current === next) return; + isPlayingRef.current = next; + setIsPlaying(next); + }; + + const commitDuration = (next: number) => { + const safe = Number.isFinite(next) && next >= 0 ? next : 0; + if (Math.abs(safe - durationRef.current) < 0.033) return; + durationRef.current = safe; + setDurationState(safe); + }; + + const commitTotalDuration = (next: number) => { + const safe = Number.isFinite(next) && next > 0 ? next : 0; + if (Math.abs(safe - totalDurationRef.current) < 0.05) return; + totalDurationRef.current = safe; + setTotalDuration(safe); + }; + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + const onPlay = () => { + if (isLoadingRef.current) return; + commitPlaying(true); + }; + + const onPause = () => { + if (isLoadingRef.current) return; + commitPlaying(false); + }; + + const onEnded = () => { + commitPlaying(false); + durationRef.current = 0; + setDurationState(0); + setCurrentMessageId(null); + }; + + const onTimeUpdate = () => { + if (isLoadingRef.current) return; + if (isSeekingRef.current) return; + if (rafTimeUpdateRef.current != null) return; + rafTimeUpdateRef.current = requestAnimationFrame(() => { + rafTimeUpdateRef.current = null; + if (!isLoadingRef.current && !isSeekingRef.current) { + commitDuration(audio.currentTime || 0); + } + }); + }; + + const onLoadedMetadata = () => commitTotalDuration(audio.duration); + const onDurationChange = () => commitTotalDuration(audio.duration); + + const onSeeked = () => { + if (isSeekingRef.current) { + isSeekingRef.current = false; + if (!isLoadingRef.current) { + commitDuration(audio.currentTime || 0); + } + return; + } + if (isLoadingRef.current) return; + commitDuration(audio.currentTime || 0); + }; + + const onCanPlay = () => { + if (isLoadingRef.current) { + isLoadingRef.current = false; + } + }; + + audio.addEventListener("play", onPlay); + audio.addEventListener("pause", onPause); + audio.addEventListener("ended", onEnded); + audio.addEventListener("timeupdate", onTimeUpdate); + audio.addEventListener("loadedmetadata", onLoadedMetadata); + audio.addEventListener("durationchange", onDurationChange); + audio.addEventListener("seeked", onSeeked); + audio.addEventListener("canplay", onCanPlay); + + return () => { + audio.removeEventListener("play", onPlay); + audio.removeEventListener("pause", onPause); + audio.removeEventListener("ended", onEnded); + audio.removeEventListener("timeupdate", onTimeUpdate); + audio.removeEventListener("loadedmetadata", onLoadedMetadata); + audio.removeEventListener("durationchange", onDurationChange); + audio.removeEventListener("seeked", onSeeked); + audio.removeEventListener("canplay", onCanPlay); + + if (rafTimeUpdateRef.current != null) { + cancelAnimationFrame(rafTimeUpdateRef.current); + rafTimeUpdateRef.current = null; + } + }; + }, []); + + useEffect(() => { + return () => { + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } + }; + }, []); + + const playAudio = ( + artist: string, + title: string, + audio: string | Blob | File, + messageId?: string | null + ) => { + const el = audioRef.current; + if (!el) return; + + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } + + const audioSrc = typeof audio === "string" ? audio : URL.createObjectURL(audio); + if (typeof audio !== "string") { + objectUrlRef.current = audioSrc; + } + + isLoadingRef.current = true; + isSeekingRef.current = false; + + el.src = audioSrc; + + durationRef.current = 0; + + const msgId = messageId ?? null; + setCurrentMessageId(msgId); + if (msgId) setLastMessageId(msgId); + + isPlayingRef.current = true; + setIsPlaying(true); + const prevDuration = durationRef.current; + requestAnimationFrame(() => { + if (durationRef.current === prevDuration) { + setDurationState(0); + } + }); + + void el.play().catch(() => { + isLoadingRef.current = false; + commitPlaying(false); + }); + }; + + const pause = () => { + const el = audioRef.current; + if (!el) return; + el.pause(); + }; + + const resume = () => { + const el = audioRef.current; + if (!el) return; + + commitPlaying(true); + + void el.play().catch(() => { + commitPlaying(false); + }); + }; + + const stop = () => { + const el = audioRef.current; + if (!el) return; + + isLoadingRef.current = true; + el.pause(); + el.currentTime = 0; + isLoadingRef.current = false; + + durationRef.current = 0; + setDurationState(0); + commitPlaying(false); + setCurrentMessageId(null); + }; + + const setDuration = (sec: number) => { + const el = audioRef.current; + if (!el) return; + + isSeekingRef.current = true; + el.currentTime = Math.max(0, sec); + commitDuration(el.currentTime || 0); + }; return ( - + {props.children} - ) + ); } \ No newline at end of file diff --git a/app/providers/PlayerProvider/usePlayerContext.ts b/app/providers/PlayerProvider/usePlayerContext.ts new file mode 100644 index 0000000..442b298 --- /dev/null +++ b/app/providers/PlayerProvider/usePlayerContext.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { PlayerContext, PlayerContextValue } from "./PlayerProvider"; + +export function usePlayerContext() : PlayerContextValue { + const context = useContext(PlayerContext); + if (!context) { + throw new Error("useAudioPlayer must be used within a PlayerProvider"); + } + return context; +} \ No newline at end of file diff --git a/app/providers/ProtocolProvider/protocol/packets/packet.message.ts b/app/providers/ProtocolProvider/protocol/packets/packet.message.ts index 01bc15b..23f84c1 100644 --- a/app/providers/ProtocolProvider/protocol/packets/packet.message.ts +++ b/app/providers/ProtocolProvider/protocol/packets/packet.message.ts @@ -6,7 +6,8 @@ export enum AttachmentType { MESSAGES = 1, FILE = 2, AVATAR = 3, - CALL = 4 + CALL = 4, + VOICE = 5 } /** diff --git a/app/utils/constructLastMessageTextByAttachments.ts b/app/utils/constructLastMessageTextByAttachments.ts index d5b0fc2..e62ae81 100644 --- a/app/utils/constructLastMessageTextByAttachments.ts +++ b/app/utils/constructLastMessageTextByAttachments.ts @@ -17,6 +17,8 @@ export const constructLastMessageTextByAttachments = (attachment: string) => { return "$a=Avatar"; case AttachmentType.CALL: return "$a=Call"; + case AttachmentType.VOICE: + return "$a=Voice message"; default: return "[Unsupported attachment]"; }