From a3aedb7c5de1d2d36aa5954ebc746eb7b3d9e2fb Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 26 Apr 2026 17:57:29 +0300 Subject: [PATCH] =?UTF-8?q?UI=20-=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E?= =?UTF-8?q?=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C?= =?UTF-8?q?=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98=D0=AF:=20=D1=81?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B8=20=D0=B8=20=D0=BF=D0=BB=D0=B5=D0=B5?= =?UTF-8?q?=D1=80=20Voice=20Tasker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../voice-tasker/global-control.tsx | 558 ++++++++++++++---- 1 file changed, 442 insertions(+), 116 deletions(-) diff --git a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx index 044b0ce..c1a83bf 100644 --- a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx +++ b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx @@ -5,10 +5,11 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ElementType, ReactNode } from "react"; +import type { ElementType, MouseEvent, ReactNode } from "react"; import { useParams } from "next/navigation"; import useSWR from "swr"; import { + AudioLines, CalendarDays, CheckCircle2, Clock3, @@ -16,15 +17,18 @@ import { Flag, FolderKanban, ListChecks, + LoaderCircle, Mic, + Pause, Pencil, - RotateCcw, + Play, Search, Square, Target, Trash2, Upload, UserRound, + Volume2, X, } from "lucide-react"; // plane imports @@ -76,9 +80,13 @@ const PRIORITY_LABELS: Record, string> = { const voiceTaskPropertyButtonClassName = "nodedc-work-item-property-button !h-8 !min-h-8 !w-full !justify-start !px-3 text-12"; +const voiceTaskCloseButtonClassName = + "absolute top-0.5 right-0.5 flex h-12 w-12 items-center justify-center rounded-full border-0 bg-[#17181B] text-white shadow-none ring-0 transition-transform outline-none hover:scale-[1.03] hover:bg-[#0F1012]"; const VOICE_TASK_TIME_HOURS = Array.from({ length: 24 }, (_, index) => index.toString().padStart(2, "0")); const VOICE_TASK_TIME_MINUTES = Array.from({ length: 60 }, (_, index) => index.toString().padStart(2, "0")); +const VOICE_TASK_TIME_WHEEL_ITEM_HEIGHT = 36; +const VOICE_TASK_WAVEFORM_BAR_COUNT = 32; function getSupportedMimeType() { if (typeof MediaRecorder === "undefined") return ""; @@ -99,6 +107,11 @@ function formatConfidence(value?: number) { return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`; } +function formatPlaybackTime(value: number) { + if (!Number.isFinite(value)) return "0:00"; + return formatDuration(value); +} + function getCurrentProjectId() { if (typeof window === "undefined") return null; const match = window.location.pathname.match(/\/projects\/([^/]+)/); @@ -178,6 +191,22 @@ function getPriorityLabel(priority: TVoiceTaskPriority) { return priority ? PRIORITY_LABELS[priority] : "Не распознано"; } +function getVoiceTaskRecordingStatusLabel({ + hasAudio, + isRecording, + status, +}: { + hasAudio: boolean; + isRecording: boolean; + status: TVoiceTaskerStatus; +}) { + if (status === "uploading") return "Формируем карточку"; + if (isRecording) return "Идет запись"; + if (hasAudio) return "Запись готова"; + if (status === "error") return "Ошибка"; + return "Готово к записи"; +} + function parseVoiceTaskTime(value?: string | null) { const match = value?.match(/^(\d{2}):(\d{2})/); if (!match) return { hour: null, minute: null }; @@ -248,9 +277,172 @@ function VoiceTaskPropertyBlock({ ); } +function VoiceTaskAudioPlayer({ audioUrl }: { audioUrl: string }) { + const audioRef = useRef(null); + const progressRef = useRef(null); + const [duration, setDuration] = useState(0); + const [currentTime, setCurrentTime] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [isVolumeOpen, setIsVolumeOpen] = useState(false); + const [volume, setVolume] = useState(1); + const progress = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0; + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + const syncDuration = () => setDuration(audio.duration || 0); + const syncTime = () => setCurrentTime(audio.currentTime || 0); + const syncPlaying = () => setIsPlaying(true); + const syncPaused = () => setIsPlaying(false); + + audio.addEventListener("loadedmetadata", syncDuration); + audio.addEventListener("durationchange", syncDuration); + audio.addEventListener("timeupdate", syncTime); + audio.addEventListener("play", syncPlaying); + audio.addEventListener("pause", syncPaused); + audio.addEventListener("ended", syncPaused); + + return () => { + audio.removeEventListener("loadedmetadata", syncDuration); + audio.removeEventListener("durationchange", syncDuration); + audio.removeEventListener("timeupdate", syncTime); + audio.removeEventListener("play", syncPlaying); + audio.removeEventListener("pause", syncPaused); + audio.removeEventListener("ended", syncPaused); + }; + }, [audioUrl]); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + + audio.volume = volume; + }, [volume]); + + const togglePlayback = () => { + const audio = audioRef.current; + if (!audio) return; + + if (audio.paused) void audio.play(); + else audio.pause(); + }; + + const seek = (event: MouseEvent) => { + const audio = audioRef.current; + const progressNode = progressRef.current; + if (!audio || !duration || !progressNode) return; + + const rect = progressNode.getBoundingClientRect(); + const nextProgress = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)); + audio.currentTime = nextProgress * duration; + }; + + return ( +
+ + +
+ {formatPlaybackTime(currentTime)} / {formatPlaybackTime(duration)} +
+ +
+ + {isVolumeOpen && ( +
+ setVolume(event.currentTarget.valueAsNumber)} + /> +
{Math.round(volume * 100)}%
+
+ )} +
+
+ ); +} + +function VoiceTaskWaveform({ isRecording, levels }: { isRecording: boolean; levels: number[] }) { + const fallbackLevels = useMemo( + () => + Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, (_, index) => { + const wave = Math.sin(index * 0.75) * 0.5 + 0.5; + return 0.18 + wave * 0.42; + }), + [] + ); + const renderedLevels = isRecording && levels.length ? levels : fallbackLevels; + + return ( +
+ +
+ {renderedLevels.map((level, index) => ( + + ))} +
+
+ ); +} + +function VoiceTaskProcessingState() { + return ( +
+
+ + + +
+
+ ); +} + function VoiceTaskTimePicker({ onChange, value }: { onChange: (value: string | null) => void; value?: string | null }) { const [isOpen, setIsOpen] = useState(false); const rootRef = useRef(null); + const hourWheelRef = useRef(null); + const minuteWheelRef = useRef(null); + const scrollEndTimersRef = useRef<{ hour: number | null; minute: number | null }>({ hour: null, minute: null }); + const isSyncingWheelsRef = useRef(false); const selectedTime = parseVoiceTaskTime(value); const selectedHour = selectedTime.hour ?? "00"; const selectedMinute = selectedTime.minute ?? "00"; @@ -274,8 +466,86 @@ function VoiceTaskTimePicker({ onChange, value }: { onChange: (value: string | n }; }, [isOpen]); + useEffect( + () => () => { + if (scrollEndTimersRef.current.hour) window.clearTimeout(scrollEndTimersRef.current.hour); + if (scrollEndTimersRef.current.minute) window.clearTimeout(scrollEndTimersRef.current.minute); + }, + [] + ); + + useEffect(() => { + if (!isOpen) return; + + const hourIndex = VOICE_TASK_TIME_HOURS.indexOf(selectedHour); + const minuteIndex = VOICE_TASK_TIME_MINUTES.indexOf(selectedMinute); + isSyncingWheelsRef.current = true; + hourWheelRef.current?.scrollTo({ top: Math.max(0, hourIndex) * VOICE_TASK_TIME_WHEEL_ITEM_HEIGHT }); + minuteWheelRef.current?.scrollTo({ top: Math.max(0, minuteIndex) * VOICE_TASK_TIME_WHEEL_ITEM_HEIGHT }); + window.setTimeout(() => { + isSyncingWheelsRef.current = false; + }, 120); + }, [isOpen, selectedHour, selectedMinute]); + const updateTime = (hour: string, minute: string) => onChange(`${hour}:${minute}`); + const snapWheel = (unit: "hour" | "minute", nextIndex: number, behavior: ScrollBehavior = "smooth") => { + const options = unit === "hour" ? VOICE_TASK_TIME_HOURS : VOICE_TASK_TIME_MINUTES; + const wheel = unit === "hour" ? hourWheelRef.current : minuteWheelRef.current; + const safeIndex = Math.max(0, Math.min(options.length - 1, nextIndex)); + const nextValue = options[safeIndex]; + + wheel?.scrollTo({ + top: safeIndex * VOICE_TASK_TIME_WHEEL_ITEM_HEIGHT, + behavior, + }); + + if (unit === "hour") updateTime(nextValue, selectedMinute); + else updateTime(selectedHour, nextValue); + }; + + const handleWheelScroll = (unit: "hour" | "minute") => { + if (isSyncingWheelsRef.current) return; + + const wheel = unit === "hour" ? hourWheelRef.current : minuteWheelRef.current; + if (!wheel) return; + + const previousTimer = scrollEndTimersRef.current[unit]; + if (previousTimer) window.clearTimeout(previousTimer); + + scrollEndTimersRef.current[unit] = window.setTimeout(() => { + const nextIndex = Math.round(wheel.scrollTop / VOICE_TASK_TIME_WHEEL_ITEM_HEIGHT); + snapWheel(unit, nextIndex); + scrollEndTimersRef.current[unit] = null; + }, 90); + }; + + const renderWheelColumn = (unit: "hour" | "minute", options: string[], selectedValue: string) => ( +
handleWheelScroll(unit)} + > + {options.map((option, index) => { + const isSelected = selectedValue === option; + + return ( + + ); + })} +
+ ); + return (
- ))} -
-
- {VOICE_TASK_TIME_MINUTES.map((minute) => ( - - ))} +
+
+
+
+
+
+
+ {renderWheelColumn("hour", VOICE_TASK_TIME_HOURS, selectedHour)} + {renderWheelColumn("minute", VOICE_TASK_TIME_MINUTES, selectedMinute)}
)} @@ -485,6 +731,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const [duration, setDuration] = useState(0); const [audioBlob, setAudioBlob] = useState(null); const [audioUrl, setAudioUrl] = useState(null); + const [audioLevels, setAudioLevels] = useState([]); const [error, setError] = useState(null); const [parseResult, setParseResult] = useState(null); const [commitResult, setCommitResult] = useState(null); @@ -495,6 +742,9 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const chunksRef = useRef([]); const timerRef = useRef(null); const startedAtRef = useRef(0); + const audioContextRef = useRef(null); + const audioSourceRef = useRef(null); + const audioVisualizerFrameRef = useRef(null); const { data: preflight } = useSWR( workspaceSlug ? `VOICE_TASK_PREFLIGHT_${workspaceSlug}` : null, @@ -521,6 +771,63 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { } }, []); + const stopAudioVisualization = useCallback(() => { + if (audioVisualizerFrameRef.current) { + window.cancelAnimationFrame(audioVisualizerFrameRef.current); + audioVisualizerFrameRef.current = null; + } + + audioSourceRef.current?.disconnect(); + audioSourceRef.current = null; + + if (audioContextRef.current && audioContextRef.current.state !== "closed") { + void audioContextRef.current.close(); + } + audioContextRef.current = null; + setAudioLevels([]); + }, []); + + const startAudioVisualization = useCallback( + (stream: MediaStream) => { + stopAudioVisualization(); + + const AudioContextClass = + window.AudioContext || + (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AudioContextClass) return; + + const audioContext = new AudioContextClass(); + const analyser = audioContext.createAnalyser(); + const source = audioContext.createMediaStreamSource(stream); + const frequencyData = new Uint8Array(analyser.frequencyBinCount); + + analyser.fftSize = 1024; + analyser.smoothingTimeConstant = 0.72; + source.connect(analyser); + audioContextRef.current = audioContext; + audioSourceRef.current = source; + void audioContext.resume(); + + const tick = () => { + analyser.getByteFrequencyData(frequencyData); + + const nextLevels = Array.from({ length: VOICE_TASK_WAVEFORM_BAR_COUNT }, (_, index) => { + const start = Math.floor((index / VOICE_TASK_WAVEFORM_BAR_COUNT) * frequencyData.length); + const end = Math.floor(((index + 1) / VOICE_TASK_WAVEFORM_BAR_COUNT) * frequencyData.length); + const slice = frequencyData.slice(start, Math.max(start + 1, end)); + const average = slice.reduce((sum, value) => sum + value, 0) / slice.length; + return Math.max(0.08, Math.min(1, average / 165)); + }); + + setAudioLevels(nextLevels); + audioVisualizerFrameRef.current = window.requestAnimationFrame(tick); + }; + + tick(); + }, + [stopAudioVisualization] + ); + const stopStream = useCallback(() => { streamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current = null; @@ -535,20 +842,23 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { return; } + stopAudioVisualization(); stopStream(); - }, [clearTimer, stopStream]); + }, [clearTimer, stopAudioVisualization, stopStream]); const resetRecording = useCallback(() => { stopRecording(); + stopAudioVisualization(); setAudioBlob(null); setAudioUrl(null); + setAudioLevels([]); setDuration(0); setError(null); setParseResult(null); setCommitResult(null); setSelectedTargetIssue(null); setStatus("idle"); - }, [stopRecording]); + }, [stopAudioVisualization, stopRecording]); const handleClose = useCallback(() => { resetRecording(); @@ -558,9 +868,10 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { useEffect( () => () => { clearTimer(); + stopAudioVisualization(); stopStream(); }, - [clearTimer, stopStream] + [clearTimer, stopAudioVisualization, stopStream] ); useEffect(() => { @@ -608,10 +919,12 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const type = recorder.mimeType || mimeType || "audio/webm"; setAudioBlob(new Blob(chunksRef.current, { type })); setStatus("idle"); + stopAudioVisualization(); stopStream(); }; recorder.start(); + startAudioVisualization(stream); startedAtRef.current = Date.now(); setDuration(0); setError(null); @@ -625,6 +938,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { } catch { setError("Не удалось получить доступ к микрофону."); setStatus("error"); + stopAudioVisualization(); stopStream(); clearTimer(); } @@ -732,6 +1046,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { title: getCommitSuccessTitle(result), message: getCommitSuccessMessage(result), }); + handleClose(); } catch (err) { const message = typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось применить Voice Task."; @@ -767,6 +1082,12 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const selectedTargetTaskId = draft?.target_task_id ?? selectedTargetTask?.id ?? null; const selectedPriority = (draft?.priority ?? "none") as TIssuePriorities; const warnings = parseResult ? getVoiceTaskWarnings(parseResult) : []; + const recordingDisplayDuration = isRecording ? Math.max(0, maxDuration - duration) : duration; + const recordingStatusLabel = getVoiceTaskRecordingStatusLabel({ + hasAudio: Boolean(audioBlob), + isRecording, + status, + }); const canPublishDraft = Boolean( parseResult?.voice_session_id && draft && @@ -806,93 +1127,99 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { width={draft ? EModalWidth.VIIXL : EModalWidth.MD} className="overflow-visible" > -
+
{!draft ? ( <> -
+

Voice Tasker

-

Запись до {maxDuration} секунд

-
-
-
-
-
{formatDuration(duration)}
-
{getCommitStatusLabel(status)}
-
-
+ ) : ( +
+
+
+
+ {formatDuration(recordingDisplayDuration)} +
+
{recordingStatusLabel}
+
+ {isRecording && ( +
+ +
)} - > -
+ +
+ {isRecording ? ( + + ) : audioUrl ? ( + + ) : ( +
+ + Готово к записи +
+ )} +
+ + {error && ( +
+ {error} +
+ )}
+ )} - {audioUrl && !isRecording && ( - - )} - - {error && ( -
- {error} -
- )} -
- -
- {audioBlob && !isRecording && ( - - )} - - -
+ {!isUploading && ( +
+ {!audioBlob && !isRecording && ( + + )} + {isRecording && ( + + )} + {audioBlob && !isRecording && ( + <> + + + + )} +
+ )} ) : ( <> -
+

Предпросмотр задачи

-
@@ -907,15 +1234,8 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{formatDuration(duration)}
{getCommitStatusLabel(status)}
-
- -
- {audioUrl && !isRecording && ( - - )} + {audioUrl && !isRecording && }
{parseResult?.transcript && ( @@ -1104,7 +1424,13 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
-