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 { 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 { useParams } from "next/navigation";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import {
|
import {
|
||||||
|
AudioLines,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock3,
|
Clock3,
|
||||||
|
|
@ -16,15 +17,18 @@ import {
|
||||||
Flag,
|
Flag,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
LoaderCircle,
|
||||||
Mic,
|
Mic,
|
||||||
|
Pause,
|
||||||
Pencil,
|
Pencil,
|
||||||
RotateCcw,
|
Play,
|
||||||
Search,
|
Search,
|
||||||
Square,
|
Square,
|
||||||
Target,
|
Target,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
UserRound,
|
UserRound,
|
||||||
|
Volume2,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
|
|
@ -76,9 +80,13 @@ const PRIORITY_LABELS: Record<Exclude<TVoiceTaskPriority, null>, string> = {
|
||||||
|
|
||||||
const voiceTaskPropertyButtonClassName =
|
const voiceTaskPropertyButtonClassName =
|
||||||
"nodedc-work-item-property-button !h-8 !min-h-8 !w-full !justify-start !px-3 text-12";
|
"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_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_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() {
|
function getSupportedMimeType() {
|
||||||
if (typeof MediaRecorder === "undefined") return "";
|
if (typeof MediaRecorder === "undefined") return "";
|
||||||
|
|
@ -99,6 +107,11 @@ function formatConfidence(value?: number) {
|
||||||
return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
|
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() {
|
function getCurrentProjectId() {
|
||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
const match = window.location.pathname.match(/\/projects\/([^/]+)/);
|
const match = window.location.pathname.match(/\/projects\/([^/]+)/);
|
||||||
|
|
@ -178,6 +191,22 @@ function getPriorityLabel(priority: TVoiceTaskPriority) {
|
||||||
return priority ? PRIORITY_LABELS[priority] : "Не распознано";
|
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) {
|
function parseVoiceTaskTime(value?: string | null) {
|
||||||
const match = value?.match(/^(\d{2}):(\d{2})/);
|
const match = value?.match(/^(\d{2}):(\d{2})/);
|
||||||
if (!match) return { hour: null, minute: null };
|
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 }) {
|
function VoiceTaskTimePicker({ onChange, value }: { onChange: (value: string | null) => void; value?: string | null }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
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 selectedTime = parseVoiceTaskTime(value);
|
||||||
const selectedHour = selectedTime.hour ?? "00";
|
const selectedHour = selectedTime.hour ?? "00";
|
||||||
const selectedMinute = selectedTime.minute ?? "00";
|
const selectedMinute = selectedTime.minute ?? "00";
|
||||||
|
|
@ -274,8 +466,86 @@ function VoiceTaskTimePicker({ onChange, value }: { onChange: (value: string | n
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [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 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 (
|
return (
|
||||||
<div ref={rootRef} className="relative h-full">
|
<div ref={rootRef} className="relative h-full">
|
||||||
<button
|
<button
|
||||||
|
|
@ -292,40 +562,16 @@ function VoiceTaskTimePicker({ onChange, value }: { onChange: (value: string | n
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="nodedc-dropdown-surface absolute top-full right-0 left-0 z-[900] mt-2 !rounded-[22px] !p-2">
|
<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="grid max-h-56 grid-cols-2 gap-2 overflow-hidden">
|
<div className="relative grid h-[252px] grid-cols-2 gap-2 overflow-hidden">
|
||||||
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
|
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-0 grid -translate-y-1/2 grid-cols-2 gap-2">
|
||||||
{VOICE_TASK_TIME_HOURS.map((hour) => (
|
<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)]" />
|
||||||
<button
|
<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)]" />
|
||||||
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -485,6 +731,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const [audioLevels, setAudioLevels] = useState<number[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
|
const [parseResult, setParseResult] = useState<TVoiceTaskUploadResult | null>(null);
|
||||||
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
|
const [commitResult, setCommitResult] = useState<TVoiceTaskCommitResult | null>(null);
|
||||||
|
|
@ -495,6 +742,9 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
const chunksRef = useRef<BlobPart[]>([]);
|
const chunksRef = useRef<BlobPart[]>([]);
|
||||||
const timerRef = useRef<number | null>(null);
|
const timerRef = useRef<number | null>(null);
|
||||||
const startedAtRef = useRef(0);
|
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(
|
const { data: preflight } = useSWR(
|
||||||
workspaceSlug ? `VOICE_TASK_PREFLIGHT_${workspaceSlug}` : null,
|
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(() => {
|
const stopStream = useCallback(() => {
|
||||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||||
streamRef.current = null;
|
streamRef.current = null;
|
||||||
|
|
@ -535,20 +842,23 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopAudioVisualization();
|
||||||
stopStream();
|
stopStream();
|
||||||
}, [clearTimer, stopStream]);
|
}, [clearTimer, stopAudioVisualization, stopStream]);
|
||||||
|
|
||||||
const resetRecording = useCallback(() => {
|
const resetRecording = useCallback(() => {
|
||||||
stopRecording();
|
stopRecording();
|
||||||
|
stopAudioVisualization();
|
||||||
setAudioBlob(null);
|
setAudioBlob(null);
|
||||||
setAudioUrl(null);
|
setAudioUrl(null);
|
||||||
|
setAudioLevels([]);
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
setParseResult(null);
|
setParseResult(null);
|
||||||
setCommitResult(null);
|
setCommitResult(null);
|
||||||
setSelectedTargetIssue(null);
|
setSelectedTargetIssue(null);
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
}, [stopRecording]);
|
}, [stopAudioVisualization, stopRecording]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
resetRecording();
|
resetRecording();
|
||||||
|
|
@ -558,9 +868,10 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
|
stopAudioVisualization();
|
||||||
stopStream();
|
stopStream();
|
||||||
},
|
},
|
||||||
[clearTimer, stopStream]
|
[clearTimer, stopAudioVisualization, stopStream]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -608,10 +919,12 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
const type = recorder.mimeType || mimeType || "audio/webm";
|
const type = recorder.mimeType || mimeType || "audio/webm";
|
||||||
setAudioBlob(new Blob(chunksRef.current, { type }));
|
setAudioBlob(new Blob(chunksRef.current, { type }));
|
||||||
setStatus("idle");
|
setStatus("idle");
|
||||||
|
stopAudioVisualization();
|
||||||
stopStream();
|
stopStream();
|
||||||
};
|
};
|
||||||
|
|
||||||
recorder.start();
|
recorder.start();
|
||||||
|
startAudioVisualization(stream);
|
||||||
startedAtRef.current = Date.now();
|
startedAtRef.current = Date.now();
|
||||||
setDuration(0);
|
setDuration(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -625,6 +938,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
} catch {
|
} catch {
|
||||||
setError("Не удалось получить доступ к микрофону.");
|
setError("Не удалось получить доступ к микрофону.");
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
|
stopAudioVisualization();
|
||||||
stopStream();
|
stopStream();
|
||||||
clearTimer();
|
clearTimer();
|
||||||
}
|
}
|
||||||
|
|
@ -732,6 +1046,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
title: getCommitSuccessTitle(result),
|
title: getCommitSuccessTitle(result),
|
||||||
message: getCommitSuccessMessage(result),
|
message: getCommitSuccessMessage(result),
|
||||||
});
|
});
|
||||||
|
handleClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message =
|
||||||
typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось применить Voice Task.";
|
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 selectedTargetTaskId = draft?.target_task_id ?? selectedTargetTask?.id ?? null;
|
||||||
const selectedPriority = (draft?.priority ?? "none") as TIssuePriorities;
|
const selectedPriority = (draft?.priority ?? "none") as TIssuePriorities;
|
||||||
const warnings = parseResult ? getVoiceTaskWarnings(parseResult) : [];
|
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(
|
const canPublishDraft = Boolean(
|
||||||
parseResult?.voice_session_id &&
|
parseResult?.voice_session_id &&
|
||||||
draft &&
|
draft &&
|
||||||
|
|
@ -806,93 +1127,99 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
width={draft ? EModalWidth.VIIXL : EModalWidth.MD}
|
width={draft ? EModalWidth.VIIXL : EModalWidth.MD}
|
||||||
className="overflow-visible"
|
className="overflow-visible"
|
||||||
>
|
>
|
||||||
<div className={cn("p-5", draft && "sm:p-6")}>
|
<div className={cn("relative p-5", draft && "sm:p-6")}>
|
||||||
{!draft ? (
|
{!draft ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4 pr-12">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-18 font-medium text-primary">Voice Tasker</h3>
|
<h3 className="text-18 font-medium text-primary">Voice Tasker</h3>
|
||||||
<p className="mt-1 text-13 text-secondary">Запись до {maxDuration} секунд</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="button" className={voiceTaskCloseButtonClassName} onClick={handleClose}>
|
||||||
type="button"
|
<X className="size-5" />
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 rounded-[24px] bg-white/[0.035] p-4 backdrop-blur-xl">
|
{isUploading ? (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<VoiceTaskProcessingState />
|
||||||
<div>
|
) : (
|
||||||
<div className="text-28 font-semibold text-primary">{formatDuration(duration)}</div>
|
<div className="mt-5 rounded-[24px] bg-white/[0.035] p-4 backdrop-blur-xl">
|
||||||
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
|
<div className="flex items-center justify-between gap-4">
|
||||||
</div>
|
<div>
|
||||||
<div
|
<div className="text-28 font-semibold text-primary">
|
||||||
className={cn(
|
{formatDuration(recordingDisplayDuration)}
|
||||||
"flex size-14 items-center justify-center rounded-full",
|
</div>
|
||||||
isRecording
|
<div className="mt-1 text-12 text-tertiary">{recordingStatusLabel}</div>
|
||||||
? "bg-red-500/15 text-red-500"
|
</div>
|
||||||
: "bg-white/[0.06] text-[rgb(var(--nodedc-accent-rgb))]"
|
{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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{audioUrl && !isRecording && (
|
{!isUploading && (
|
||||||
<audio controls src={audioUrl} className="mt-4 w-full">
|
<div className="mt-5 flex flex-wrap justify-end gap-2">
|
||||||
<track kind="captions" />
|
{!audioBlob && !isRecording && (
|
||||||
</audio>
|
<Button variant="primary" size="lg" onClick={startRecording} disabled={isCommitting}>
|
||||||
)}
|
<Mic className="mr-2 size-4" />
|
||||||
|
Записать
|
||||||
{error && (
|
</Button>
|
||||||
<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}
|
{isRecording && (
|
||||||
</div>
|
<Button variant="error-fill" size="lg" onClick={stopRecording} disabled={isCommitting}>
|
||||||
)}
|
<Square className="mr-2 size-4" />
|
||||||
</div>
|
Завершить запись
|
||||||
|
</Button>
|
||||||
<div className="mt-5 flex flex-wrap justify-end gap-2">
|
)}
|
||||||
{audioBlob && !isRecording && (
|
{audioBlob && !isRecording && (
|
||||||
<Button variant="secondary" size="lg" onClick={resetRecording} disabled={isUploading || isCommitting}>
|
<>
|
||||||
<RotateCcw className="mr-2 size-4" />
|
<Button
|
||||||
Перезаписать
|
variant="secondary"
|
||||||
</Button>
|
size="lg"
|
||||||
)}
|
className="min-w-[9.5rem] !px-5"
|
||||||
<Button
|
onClick={resetRecording}
|
||||||
variant={isRecording ? "error-fill" : "secondary"}
|
disabled={isCommitting}
|
||||||
size="lg"
|
>
|
||||||
onClick={isRecording ? stopRecording : startRecording}
|
<Mic className="mr-2 size-4" />
|
||||||
disabled={isUploading || isCommitting}
|
Перезаписать
|
||||||
>
|
</Button>
|
||||||
{isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />}
|
<Button variant="primary" size="lg" onClick={uploadAudio} disabled={isCommitting}>
|
||||||
{isRecording ? "Стоп" : "Записать"}
|
<Upload className="mr-2 size-4" />
|
||||||
</Button>
|
Сформировать карточку
|
||||||
<Button
|
</Button>
|
||||||
variant="primary"
|
</>
|
||||||
size="lg"
|
)}
|
||||||
onClick={uploadAudio}
|
</div>
|
||||||
loading={isUploading}
|
)}
|
||||||
disabled={!audioBlob || isRecording || 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>
|
<div>
|
||||||
<h3 className="text-22 font-semibold text-primary">Предпросмотр задачи</h3>
|
<h3 className="text-22 font-semibold text-primary">Предпросмотр задачи</h3>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="button" className={voiceTaskCloseButtonClassName} onClick={handleClose}>
|
||||||
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}
|
|
||||||
>
|
|
||||||
<X className="size-5" />
|
<X className="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -907,15 +1234,8 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
<div className="text-28 font-semibold text-primary">{formatDuration(duration)}</div>
|
<div className="text-28 font-semibold text-primary">{formatDuration(duration)}</div>
|
||||||
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
|
<div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
|
||||||
</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>
|
</div>
|
||||||
{audioUrl && !isRecording && (
|
{audioUrl && !isRecording && <VoiceTaskAudioPlayer audioUrl={audioUrl} />}
|
||||||
<audio controls src={audioUrl} className="mt-4 w-full">
|
|
||||||
<track kind="captions" />
|
|
||||||
</audio>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{parseResult?.transcript && (
|
{parseResult?.transcript && (
|
||||||
|
|
@ -1104,7 +1424,13 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap justify-end gap-2">
|
<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" />
|
<Mic className="mr-2 size-4" />
|
||||||
Перезаписать
|
Перезаписать
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue