UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: состояния записи и плеер Voice Tasker
This commit is contained in:
parent
323b4b964e
commit
a3aedb7c5d
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue