349 lines
9.9 KiB
TypeScript
349 lines
9.9 KiB
TypeScript
"use client"
|
||
|
||
import type { IVerifyStepProps } from "./VerifyStep.d"
|
||
import type { JSX } from "react"
|
||
|
||
import { MediaPlayer, MediaProvider } from "@vidstack/react"
|
||
import {
|
||
defaultLayoutIcons,
|
||
DefaultVideoLayout,
|
||
} from "@vidstack/react/player/layouts/default"
|
||
|
||
import "@vidstack/react/player/styles/default/theme.css"
|
||
import "@vidstack/react/player/styles/default/layouts/video.css"
|
||
|
||
import {
|
||
AlertTriangle,
|
||
CheckCircle,
|
||
FileVideo,
|
||
HardDrive,
|
||
Info,
|
||
Monitor,
|
||
Music,
|
||
RefreshCw,
|
||
} from "lucide-react"
|
||
import {
|
||
FunctionComponent,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useState,
|
||
} from "react"
|
||
|
||
import cs from "classnames"
|
||
|
||
import api from "@shared/api"
|
||
import { useWizard } from "@shared/context/WizardContext"
|
||
import { useTaskProgressState } from "@shared/hooks/useTaskProgressState"
|
||
import { Badge, Button, CircularProgress } from "@shared/ui"
|
||
import { StaticLoader } from "@shared/ui/Loader"
|
||
|
||
import { buildCancelJobPayload, useCancelJob } from "../useCancelJob"
|
||
import styles from "./VerifyStep.module.scss"
|
||
|
||
function formatFileSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} Б`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`
|
||
if (bytes < 1024 * 1024 * 1024)
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`
|
||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} ГБ`
|
||
}
|
||
|
||
const ERROR_CONVERT_FAILED = "Не удалось запустить конвертацию"
|
||
|
||
export const VerifyStep: FunctionComponent<IVerifyStepProps> = ({
|
||
className,
|
||
}): JSX.Element => {
|
||
const {
|
||
primaryFileKey,
|
||
videoUrl,
|
||
originalFileName,
|
||
activeJobId,
|
||
activeJobType,
|
||
goBack,
|
||
confirmVerify,
|
||
setFileKey,
|
||
setActiveJob,
|
||
startMediaConvert,
|
||
} = useWizard()
|
||
|
||
const [convertError, setConvertError] = useState<string | null>(null)
|
||
const { mutate: cancelJob, isPending: isCancelling } = useCancelJob()
|
||
|
||
/* Derive conversion state from wizard-persisted activeJob */
|
||
const convertJobId = activeJobType === "MEDIA_CONVERT" ? activeJobId : null
|
||
const convertStatus: "idle" | "converting" | "failed" = convertJobId
|
||
? "converting"
|
||
: convertError
|
||
? "failed"
|
||
: "idle"
|
||
|
||
const { data: probeData, isPending: isProbing } = api.useQuery(
|
||
"get",
|
||
"/api/media/get_meta/",
|
||
{ params: { query: { file_path: primaryFileKey ?? "" } } },
|
||
{ enabled: !!primaryFileKey },
|
||
)
|
||
|
||
const mediaInfo = useMemo(() => {
|
||
if (!probeData) return null
|
||
const videoStream = probeData.streams?.find((s) => s.codec_type === "video")
|
||
const audioStream = probeData.streams?.find((s) => s.codec_type === "audio")
|
||
const format = probeData.format
|
||
const rawName = originalFileName ?? primaryFileKey?.split("/").pop() ?? null
|
||
const actualFileName = primaryFileKey?.split("/").pop() ?? rawName
|
||
const ext = actualFileName?.split(".").pop()?.toUpperCase() ?? null
|
||
return {
|
||
filename: rawName,
|
||
size: format?.size ? Number(format.size) : null,
|
||
formatName: ext,
|
||
width: videoStream?.width ?? null,
|
||
height: videoStream?.height ?? null,
|
||
audioCodec: audioStream?.codec_name ?? null,
|
||
}
|
||
}, [probeData, originalFileName, primaryFileKey])
|
||
|
||
const needsConversion = useMemo(() => {
|
||
if (!mediaInfo?.formatName) return false
|
||
return mediaInfo.formatName !== "MP4"
|
||
}, [mediaInfo])
|
||
|
||
/* ---- Conversion logic ---- */
|
||
|
||
const handleConvert = useCallback(() => {
|
||
void startMediaConvert().catch(() => {
|
||
setConvertError(ERROR_CONVERT_FAILED)
|
||
})
|
||
}, [startMediaConvert])
|
||
|
||
const {
|
||
progressPct: convertProgressPct,
|
||
message: convertMessage,
|
||
status: convertTaskStatus,
|
||
errorMessage: convertErrorMessage,
|
||
} = useTaskProgressState({
|
||
jobId: convertJobId,
|
||
enabled: !!convertJobId && convertStatus === "converting",
|
||
defaultMessage: "Конвертация видео...",
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (!convertJobId || convertStatus !== "converting") return
|
||
|
||
if (convertTaskStatus === "FAILED") {
|
||
setActiveJob(null)
|
||
setConvertError(convertErrorMessage ?? "Ошибка конвертации")
|
||
}
|
||
}, [convertErrorMessage, convertJobId, convertStatus, convertTaskStatus, setActiveJob])
|
||
|
||
/* ---- Handlers ---- */
|
||
|
||
const handleReplace = () => {
|
||
void setFileKey(null, null, null)
|
||
}
|
||
|
||
const handleNext = () => {
|
||
void confirmVerify()
|
||
}
|
||
|
||
/* ---- Converting view ---- */
|
||
|
||
if (convertStatus === "converting") {
|
||
return (
|
||
<div className={cs(styles.root, className)} data-testid="VerifyStep">
|
||
<div className={styles.convertingContent}>
|
||
<div className={styles.progressWrapper}>
|
||
<CircularProgress
|
||
percentage={convertProgressPct}
|
||
size={200}
|
||
strokeWidth={8}
|
||
color="var(--color-success)"
|
||
className={styles.circle}
|
||
bgClassName={styles.circleBg}
|
||
valueClassName={styles.circleValue}
|
||
/>
|
||
<div className={styles.progressInner}>
|
||
<span className={styles.percentage}>
|
||
{Math.round(convertProgressPct)}%
|
||
</span>
|
||
<span className={styles.statusLabel}>КОНВЕРТАЦИЯ</span>
|
||
</div>
|
||
</div>
|
||
|
||
<p className={styles.convertDescription}>{convertMessage}</p>
|
||
|
||
<div className={styles.convertInfoCard}>
|
||
<Info size={16} className={styles.convertInfoIcon} />
|
||
<span>
|
||
Конвертация выполняется на сервере. Вы можете покинуть страницу —
|
||
прогресс сохранится.
|
||
</span>
|
||
</div>
|
||
|
||
<Button
|
||
variant="danger"
|
||
size="sm"
|
||
onClick={() => {
|
||
if (!convertJobId || isCancelling) return
|
||
|
||
cancelJob(buildCancelJobPayload(convertJobId), {
|
||
onSuccess: () => {
|
||
setActiveJob(null)
|
||
},
|
||
})
|
||
}}
|
||
disabled={isCancelling}
|
||
>
|
||
{isCancelling ? "Отмена..." : "Отменить конвертацию"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ---- Normal / needs-conversion view ---- */
|
||
|
||
return (
|
||
<div className={cs(styles.root, className)} data-testid="VerifyStep">
|
||
<div className={styles.layout}>
|
||
{/* Video player */}
|
||
<div className={styles.playerWrapper}>
|
||
{isProbing && primaryFileKey ? (
|
||
<div className={styles.placeholder}>
|
||
<StaticLoader block description="Анализ видеофайла..." />
|
||
</div>
|
||
) : needsConversion ? (
|
||
<div className={styles.placeholder}>
|
||
<FileVideo size={48} />
|
||
<p>Формат {mediaInfo?.formatName ?? ""} не поддерживается</p>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={handleConvert}
|
||
disabled={!primaryFileKey}
|
||
>
|
||
Конвертировать в MP4
|
||
</Button>
|
||
</div>
|
||
) : videoUrl ? (
|
||
<MediaPlayer src={videoUrl} crossOrigin="" playsInline>
|
||
<MediaProvider />
|
||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||
</MediaPlayer>
|
||
) : (
|
||
<div className={styles.placeholder}>
|
||
<FileVideo size={48} />
|
||
<p>Видео не загружено</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Info sidebar */}
|
||
<div className={styles.sidebar}>
|
||
<div className={styles.statusRow}>
|
||
{isProbing && primaryFileKey ? (
|
||
<Badge variant="info">
|
||
<Info size={14} />
|
||
Анализ файла...
|
||
</Badge>
|
||
) : needsConversion ? (
|
||
<Badge variant="warning">
|
||
<AlertTriangle size={14} />
|
||
Требуется конвертация
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="success">
|
||
<CheckCircle size={14} />
|
||
Готово к обработке
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
<div className={styles.infoCards}>
|
||
<div className={styles.infoCard}>
|
||
<FileVideo size={16} className={styles.infoIcon} />
|
||
<div className={styles.infoContent}>
|
||
<span className={styles.infoLabel}>Файл</span>
|
||
<span className={styles.infoValue}>
|
||
{mediaInfo?.filename ?? "—"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className={styles.infoCard}>
|
||
<HardDrive size={16} className={styles.infoIcon} />
|
||
<div className={styles.infoContent}>
|
||
<span className={styles.infoLabel}>Размер и формат</span>
|
||
<span className={styles.infoValue}>
|
||
{mediaInfo?.size ? formatFileSize(mediaInfo.size) : "—"}{" "}
|
||
· {mediaInfo?.formatName ?? "—"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className={styles.infoCard}>
|
||
<Monitor size={16} className={styles.infoIcon} />
|
||
<div className={styles.infoContent}>
|
||
<span className={styles.infoLabel}>Разрешение</span>
|
||
<span className={styles.infoValue}>
|
||
{mediaInfo?.width && mediaInfo?.height
|
||
? `${mediaInfo.width}x${mediaInfo.height}`
|
||
: "—"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className={styles.infoCard}>
|
||
<Music size={16} className={styles.infoIcon} />
|
||
<div className={styles.infoContent}>
|
||
<span className={styles.infoLabel}>Аудиокодек</span>
|
||
<span className={styles.infoValue}>
|
||
{mediaInfo?.audioCodec ?? "—"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.sidebarActions}>
|
||
{needsConversion ? (
|
||
<>
|
||
{convertError && (
|
||
<p className={styles.convertErrorText}>{convertError}</p>
|
||
)}
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
onClick={handleConvert}
|
||
disabled={!primaryFileKey}
|
||
>
|
||
Конвертировать в MP4
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
<Button variant="danger" size="sm" onClick={handleReplace}>
|
||
<RefreshCw size={14} />
|
||
Заменить видео
|
||
</Button>
|
||
{!needsConversion && (
|
||
<Button variant="outline" size="sm" disabled>
|
||
Предварительная обрезка
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className={styles.footer}>
|
||
<Button variant="outline" onClick={goBack}>
|
||
Назад
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
onClick={handleNext}
|
||
disabled={needsConversion}
|
||
>
|
||
Далее: Настройки тишины
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|