UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: состояния записи и плеер Voice Tasker

This commit is contained in:
DCCONSTRUCTIONS 2026-04-26 17:57:29 +03:00
parent 323b4b964e
commit a3aedb7c5d
1 changed files with 442 additions and 116 deletions

View File

@ -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<Exclude<TVoiceTaskPriority, null>, 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<HTMLAudioElement | null>(null);
const progressRef = useRef<HTMLButtonElement | null>(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<HTMLButtonElement>) => {
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 (
<div className="mt-4 flex items-center gap-3 text-[rgb(var(--nodedc-accent-rgb))]">
<audio ref={audioRef} src={audioUrl}>
<track kind="captions" />
</audio>
<button
type="button"
className="flex size-8 flex-shrink-0 items-center justify-center rounded-full text-[rgb(var(--nodedc-accent-rgb))] transition outline-none hover:bg-[rgb(var(--nodedc-accent-rgb))]/10"
onClick={togglePlayback}
>
{isPlaying ? <Pause className="size-4 fill-current" /> : <Play className="size-4 fill-current" />}
</button>
<div className="w-20 flex-shrink-0 text-12 font-medium text-secondary">
{formatPlaybackTime(currentTime)} / {formatPlaybackTime(duration)}
</div>
<button ref={progressRef} type="button" className="relative h-5 min-w-0 flex-1 outline-none" onClick={seek}>
<span className="absolute top-1/2 right-0 left-0 h-1 -translate-y-1/2 rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/20" />
<span
className="absolute top-1/2 left-0 h-1 -translate-y-1/2 rounded-full bg-[rgb(var(--nodedc-accent-rgb))]"
style={{ width: `${progress}%` }}
/>
</button>
<div className="relative flex flex-shrink-0">
<button
type="button"
className="flex size-8 items-center justify-center rounded-full text-[rgb(var(--nodedc-accent-rgb))] transition outline-none hover:bg-[rgb(var(--nodedc-accent-rgb))]/10"
onClick={() => setIsVolumeOpen((current) => !current)}
>
<Volume2 className="size-4" />
</button>
{isVolumeOpen && (
<div className="nodedc-dropdown-surface absolute right-0 bottom-full z-[920] mb-2 w-36 !rounded-[18px] !p-3">
<input
type="range"
min="0"
max="1"
step="0.05"
value={volume}
className="h-1 w-full accent-[rgb(var(--nodedc-accent-rgb))]"
onChange={(event) => setVolume(event.currentTarget.valueAsNumber)}
/>
<div className="mt-2 text-center text-11 font-medium text-secondary">{Math.round(volume * 100)}%</div>
</div>
)}
</div>
</div>
);
}
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 (
<div className="relative h-28 overflow-hidden rounded-[28px] bg-black/20 px-5">
<span className="absolute top-1/2 right-5 left-5 h-px -translate-y-1/2 bg-[rgb(var(--nodedc-accent-rgb))]/20" />
<div
className="relative z-10 grid h-full items-center justify-items-center gap-1"
style={{ gridTemplateColumns: `repeat(${VOICE_TASK_WAVEFORM_BAR_COUNT}, minmax(0, 1fr))` }}
>
{renderedLevels.map((level, index) => (
<span
key={index}
className={cn(
"w-1.5 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] transition-[height,opacity] duration-100",
!isRecording && "animate-pulse"
)}
style={{
height: `${Math.round(16 + Math.max(0, Math.min(1, level)) * 76)}px`,
opacity: isRecording ? 0.5 + Math.min(1, level) * 0.5 : 0.42,
animationDelay: `${index * 35}ms`,
}}
/>
))}
</div>
</div>
);
}
function VoiceTaskProcessingState() {
return (
<div className="mt-6 flex items-center justify-center py-12">
<div className="relative flex size-28 items-center justify-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/10">
<span className="absolute inset-2 rounded-full border border-[rgb(var(--nodedc-accent-rgb))]/20" />
<span className="absolute inset-0 rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/10 blur-2xl" />
<LoaderCircle className="relative size-12 animate-spin text-[rgb(var(--nodedc-accent-rgb))]" />
</div>
</div>
);
}
function VoiceTaskTimePicker({ onChange, value }: { onChange: (value: string | null) => void; value?: string | null }) {
const [isOpen, setIsOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const hourWheelRef = useRef<HTMLDivElement | null>(null);
const minuteWheelRef = useRef<HTMLDivElement | null>(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) => (
<div
ref={unit === "hour" ? hourWheelRef : minuteWheelRef}
className="relative z-10 h-[252px] [scroll-snap-type:y_mandatory] overflow-y-auto overscroll-contain py-[108px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
onScroll={() => handleWheelScroll(unit)}
>
{options.map((option, index) => {
const isSelected = selectedValue === option;
return (
<button
key={option}
type="button"
className={cn(
"mx-auto flex h-9 w-9 snap-center items-center justify-center rounded-full text-12 leading-none font-semibold text-secondary transition outline-none hover:text-primary",
isSelected && "text-black"
)}
onClick={() => snapWheel(unit, index)}
>
{option}
</button>
);
})}
</div>
);
return (
<div ref={rootRef} className="relative h-full">
<button
@ -292,40 +562,16 @@ function VoiceTaskTimePicker({ onChange, value }: { onChange: (value: string | n
</button>
{isOpen && (
<div className="nodedc-dropdown-surface absolute top-full right-0 left-0 z-[900] mt-2 !rounded-[22px] !p-2">
<div className="grid max-h-56 grid-cols-2 gap-2 overflow-hidden">
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{VOICE_TASK_TIME_HOURS.map((hour) => (
<button
key={hour}
type="button"
className={cn(
"mx-auto flex size-8 items-center justify-center rounded-full text-12 font-semibold text-secondary transition outline-none hover:bg-white/[0.07] hover:text-primary",
selectedHour === hour &&
"bg-[rgb(var(--nodedc-accent-rgb))] text-black hover:bg-[rgb(var(--nodedc-accent-rgb))] hover:text-black"
)}
onClick={() => updateTime(hour, selectedMinute)}
>
{hour}
</button>
))}
</div>
<div className="max-h-56 space-y-1 overflow-y-auto pl-1">
{VOICE_TASK_TIME_MINUTES.map((minute) => (
<button
key={minute}
type="button"
className={cn(
"mx-auto flex size-8 items-center justify-center rounded-full text-12 font-semibold text-secondary transition outline-none hover:bg-white/[0.07] hover:text-primary",
selectedMinute === minute &&
"bg-[rgb(var(--nodedc-accent-rgb))] text-black hover:bg-[rgb(var(--nodedc-accent-rgb))] hover:text-black"
)}
onClick={() => updateTime(selectedHour, minute)}
>
{minute}
</button>
))}
<div className="nodedc-dropdown-surface absolute top-full right-0 left-0 z-[900] mt-2 min-w-[11rem] !rounded-[24px] !p-2">
<div className="relative grid h-[252px] grid-cols-2 gap-2 overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-0 grid -translate-y-1/2 grid-cols-2 gap-2">
<div className="mx-auto size-9 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] shadow-[0_0_28px_rgba(var(--nodedc-accent-rgb),0.28)]" />
<div className="mx-auto size-9 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] shadow-[0_0_28px_rgba(var(--nodedc-accent-rgb),0.28)]" />
</div>
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-14 bg-gradient-to-b from-[rgba(8,8,11,0.96)] to-transparent" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-14 bg-gradient-to-t from-[rgba(8,8,11,0.96)] to-transparent" />
{renderWheelColumn("hour", VOICE_TASK_TIME_HOURS, selectedHour)}
{renderWheelColumn("minute", VOICE_TASK_TIME_MINUTES, selectedMinute)}
</div>
</div>
)}
@ -485,6 +731,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const [duration, setDuration] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [audioLevels, setAudioLevels] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null);
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
@ -495,6 +742,9 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const chunksRef = useRef<BlobPart[]>([]);
const timerRef = useRef<number | null>(null);
const startedAtRef = useRef(0);
const audioContextRef = useRef<AudioContext | null>(null);
const audioSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const audioVisualizerFrameRef = useRef<number | null>(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"
>
<div className={cn("p-5", draft && "sm:p-6")}>
<div className={cn("relative p-5", draft && "sm:p-6")}>
{!draft ? (
<>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start justify-between gap-4 pr-12">
<div>
<h3 className="text-18 font-medium text-primary">Voice Tasker</h3>
<p className="mt-1 text-13 text-secondary">Запись до {maxDuration} секунд</p>
</div>
<button
type="button"
className="flex size-9 items-center justify-center rounded-full bg-white/[0.045] text-secondary transition outline-none hover:bg-white/[0.075] hover:text-primary"
onClick={handleClose}
>
<X className="size-4" />
<button type="button" className={voiceTaskCloseButtonClassName} onClick={handleClose}>
<X className="size-5" />
</button>
</div>
<div className="mt-5 rounded-[24px] bg-white/[0.035] p-4 backdrop-blur-xl">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-28 font-semibold text-primary">{formatDuration(duration)}</div>
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
</div>
<div
className={cn(
"flex size-14 items-center justify-center rounded-full",
isRecording
? "bg-red-500/15 text-red-500"
: "bg-white/[0.06] text-[rgb(var(--nodedc-accent-rgb))]"
{isUploading ? (
<VoiceTaskProcessingState />
) : (
<div className="mt-5 rounded-[24px] bg-white/[0.035] p-4 backdrop-blur-xl">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-28 font-semibold text-primary">
{formatDuration(recordingDisplayDuration)}
</div>
<div className="mt-1 text-12 text-tertiary">{recordingStatusLabel}</div>
</div>
{isRecording && (
<div className="flex size-14 items-center justify-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))]/15 text-[rgb(var(--nodedc-accent-rgb))]">
<Mic className="size-6 animate-pulse" />
</div>
)}
>
<Mic className={cn("size-6", { "animate-pulse": isRecording })} />
</div>
<div className="mt-4">
{isRecording ? (
<VoiceTaskWaveform isRecording={isRecording} levels={audioLevels} />
) : audioUrl ? (
<VoiceTaskAudioPlayer audioUrl={audioUrl} />
) : (
<div className="flex h-28 flex-col items-center justify-center gap-3 rounded-[28px] bg-black/20 text-[rgb(var(--nodedc-accent-rgb))]">
<AudioLines className="size-8" />
<span className="text-12 text-secondary">Готово к записи</span>
</div>
)}
</div>
{error && (
<div className="border-red-500/25 bg-red-500/10 text-red-500 mt-4 rounded-[18px] border px-3 py-2 text-12">
{error}
</div>
)}
</div>
)}
{audioUrl && !isRecording && (
<audio controls src={audioUrl} className="mt-4 w-full">
<track kind="captions" />
</audio>
)}
{error && (
<div className="border-red-500/25 bg-red-500/10 text-red-500 mt-4 rounded-[18px] border px-3 py-2 text-12">
{error}
</div>
)}
</div>
<div className="mt-5 flex flex-wrap justify-end gap-2">
{audioBlob && !isRecording && (
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
<RotateCcw className="mr-2 size-4" />
Перезаписать
</Button>
)}
<Button
variant={isRecording ? "error-fill" : "secondary"}
size="lg"
onClick={isRecording ? stopRecording : startRecording}
disabled={isUploading || isCommitting}
>
{isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />}
{isRecording ? "Стоп" : "Записать"}
</Button>
<Button
variant="primary"
size="lg"
onClick={uploadAudio}
loading={isUploading}
disabled={!audioBlob || isRecording || isCommitting}
>
<Upload className="mr-2 size-4" />
Сформировать карточку
</Button>
</div>
{!isUploading && (
<div className="mt-5 flex flex-wrap justify-end gap-2">
{!audioBlob && !isRecording && (
<Button variant="primary" size="lg" onClick={startRecording} disabled={isCommitting}>
<Mic className="mr-2 size-4" />
Записать
</Button>
)}
{isRecording && (
<Button variant="error-fill" size="lg" onClick={stopRecording} disabled={isCommitting}>
<Square className="mr-2 size-4" />
Завершить запись
</Button>
)}
{audioBlob && !isRecording && (
<>
<Button
variant="secondary"
size="lg"
className="min-w-[9.5rem] !px-5"
onClick={resetRecording}
disabled={isCommitting}
>
<Mic className="mr-2 size-4" />
Перезаписать
</Button>
<Button variant="primary" size="lg" onClick={uploadAudio} disabled={isCommitting}>
<Upload className="mr-2 size-4" />
Сформировать карточку
</Button>
</>
)}
</div>
)}
</>
) : (
<>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-wrap items-start justify-between gap-4 pr-12">
<div>
<h3 className="text-22 font-semibold text-primary">Предпросмотр задачи</h3>
</div>
<button
type="button"
className="flex size-10 items-center justify-center rounded-full bg-white/[0.045] text-secondary transition outline-none hover:bg-white/[0.075] hover:text-primary"
onClick={handleClose}
>
<button type="button" className={voiceTaskCloseButtonClassName} onClick={handleClose}>
<X className="size-5" />
</button>
</div>
@ -907,15 +1234,8 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<div className="text-28 font-semibold text-primary">{formatDuration(duration)}</div>
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
</div>
<div className="flex size-12 items-center justify-center rounded-full bg-black/45 text-[rgb(var(--nodedc-accent-rgb))]">
<Mic className="size-5" />
</div>
</div>
{audioUrl && !isRecording && (
<audio controls src={audioUrl} className="mt-4 w-full">
<track kind="captions" />
</audio>
)}
{audioUrl && !isRecording && <VoiceTaskAudioPlayer audioUrl={audioUrl} />}
</div>
{parseResult?.transcript && (
@ -1104,7 +1424,13 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
</div>
<div className="mt-6 flex flex-wrap justify-end gap-2">
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
<Button
variant="secondary"
size="lg"
className="min-w-[9.5rem] !px-5"
onClick={resetRecording}
disabled={isUploading || isCommitting}
>
<Mic className="mr-2 size-4" />
Перезаписать
</Button>