import { useDraggable } from "@dnd-kit/core" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { ApplyButton } from "@/features/browser" import { AddMediaButton } from "@/features/browser/components/layout/add-media-button" import { FavoriteButton } from "@/features/browser/components/layout/favorite-button" import { MediaFile } from "@/features/media/types/media" import { TransitionResource } from "@/features/resources/types" import { Transition } from "@/features/transitions/types/transitions" import { convertVideoSrc } from "@/lib/tauri-utils" import { useTransitions } from "../hooks/use-transitions" /** * Интерфейс пропсов для компонента TransitionPreview */ interface TransitionPreviewProps { transition?: Transition sourceVideo: MediaFile targetVideo: MediaFile transitionType: string onClick: () => void size: number previewWidth?: number previewHeight?: number } /** * Компонент для отображения превью перехода между видео * Показывает анимированный переход между двумя видео и позволяет добавить переход в проект */ export function TransitionPreview({ transition, sourceVideo, targetVideo, transitionType, onClick, size, previewWidth, previewHeight, }: TransitionPreviewProps) { const { t } = useTranslation() // Хук для интернационализации const [isHovering, setIsHovering] = useState(false) // Состояние наведения мыши const [isError, setIsError] = useState(false) // Состояние ошибки загрузки видео // Ссылки на элементы видео и таймеры const sourceVideoRef = useRef(null) // Ссылка на исходное видео const targetVideoRef = useRef(null) // Ссылка на целевое видео const transitionTimeoutRef = useRef(null) // Таймер для запуска перехода const loopTimeoutRef = useRef(null) // Таймер для зацикливания демонстрации // Получаем переходы из хука const { transitions } = useTransitions() // Получаем переход из пропсов или находим по типу const currentTransition = transition || transitions.find((t: Transition) => t.id === transitionType || t.type === transitionType) // Настраиваем drag & drop const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `transition-${currentTransition?.id || transitionType}`, data: { type: "transition", data: currentTransition || { id: transitionType, type: transitionType, labels: { ru: transitionType, en: transitionType }, category: "basic", complexity: "basic", duration: { min: 0.5, max: 2.0, default: 1.0 }, }, }, }) // Вычисляем размеры превью с учетом aspect ratio const { actualWidth, actualHeight } = useMemo(() => { // Если переданы конкретные размеры, используем их if (previewWidth && previewHeight) { return { actualWidth: previewWidth, actualHeight: previewHeight } } // Иначе используем квадратный размер как fallback return { actualWidth: size, actualHeight: size } }, [previewWidth, previewHeight, size]) // Мемоизируем объекты для кнопок const favoriteFile = useMemo( () => ({ id: currentTransition?.id || transitionType, path: "", name: currentTransition?.labels?.ru || transitionType, }), [currentTransition?.id, currentTransition?.labels?.ru, transitionType], ) // Функция для получения индикатора сложности const getComplexityIndicator = (complexity: string) => { switch (complexity) { case "basic": return { color: "bg-green-500", label: "BSC" } case "intermediate": return { color: "bg-yellow-500", label: "INT" } case "advanced": return { color: "bg-red-500", label: "ADV" } default: return { color: "bg-gray-500", label: "UNK" } } } // Функция для получения индикатора категории const getCategoryIndicator = (category: string) => { switch (category) { case "basic": return "BSC" case "advanced": return "ADV" case "creative": return "CRE" case "3d": return "3D" case "artistic": return "ART" case "cinematic": return "CIN" default: return "UNK" } } const complexityIndicator = useMemo( () => getComplexityIndicator(currentTransition?.complexity || "basic"), [currentTransition?.complexity], ) const categoryIndicator = useMemo( () => getCategoryIndicator(currentTransition?.category || "basic"), [currentTransition?.category], ) /** * Сбрасывает состояние видео элементов к начальному * Устанавливает время воспроизведения на начало и сбрасывает все CSS-стили */ const resetVideos = useCallback(() => { if (!sourceVideoRef.current || !targetVideoRef.current) return const sourceVideo = sourceVideoRef.current const targetVideo = targetVideoRef.current // Сбрасываем время воспроизведения sourceVideo.currentTime = 0 targetVideo.currentTime = 0 // Сброс всех возможных стилей для исходного видео sourceVideo.style.transform = "scale(1)" sourceVideo.style.opacity = "1" sourceVideo.style.filter = "blur(0px) wave(0, 0%, 0%)" sourceVideo.style.clipPath = "none" sourceVideo.style.transition = "all 0.8s cubic-bezier(0.4, 0, 0.2, 1)" sourceVideo.style.mixBlendMode = "normal" // Сброс всех возможных стилей для целевого видео targetVideo.style.opacity = "0" // Целевое видео изначально скрыто targetVideo.style.transform = "scale(1)" targetVideo.style.filter = "blur(0px)" targetVideo.style.clipPath = "none" targetVideo.style.transition = "all 0.8s cubic-bezier(0.4, 0, 0.2, 1)" targetVideo.style.mixBlendMode = "normal" }, []) /** * Запускает анимацию перехода между видео * Сначала показывает исходное видео, затем применяет эффект перехода и показывает целевое видео */ const startTransition = useCallback(() => { if (!sourceVideoRef.current || !targetVideoRef.current || isError) return const sourceVideo = sourceVideoRef.current const targetVideo = targetVideoRef.current // Сбрасываем состояние видео перед началом перехода resetVideos() // Запускаем воспроизведение исходного видео void sourceVideo.play() // Устанавливаем таймер для начала перехода через 1 секунду transitionTimeoutRef.current = setTimeout(() => { // Показываем целевое видео targetVideo.style.opacity = "1" // Применяем соответствующий эффект перехода в зависимости от типа switch (transitionType) { case "zoom": // Эффект увеличения: исходное видео увеличивается и исчезает sourceVideo.style.transform = "scale(2)" sourceVideo.style.opacity = "0" break case "fade": // Эффект затухания: исходное видео плавно исчезает sourceVideo.style.opacity = "0" break case "slide": // Эффект слайда: исходное видео уезжает влево sourceVideo.style.transform = "translateX(-100%)" break case "scale": // Эффект масштабирования: исходное видео уменьшается и исчезает sourceVideo.style.transform = "scale(0.1)" sourceVideo.style.opacity = "0" targetVideo.style.transform = "scale(1)" break case "rotate": // Эффект вращения: исходное видео вращается и уменьшается sourceVideo.style.transform = "rotate(180deg) scale(0.5)" sourceVideo.style.opacity = "0" targetVideo.style.transform = "rotate(0deg) scale(1)" break case "flip": // Эффект переворота: исходное видео переворачивается по оси Y sourceVideo.style.transform = "rotateY(180deg)" sourceVideo.style.opacity = "0" targetVideo.style.transform = "rotateY(0deg)" break case "push": // Эффект выталкивания: исходное видео выталкивается влево sourceVideo.style.transform = "translateX(-100%)" targetVideo.style.transform = "translateX(0%)" targetVideo.style.transition = "transform 1s ease-in-out" break case "squeeze": // Эффект сжатия: исходное видео сжимается по горизонтали sourceVideo.style.transform = "scaleX(0)" sourceVideo.style.opacity = "0" break case "diagonal": // Эффект диагонали: исходное видео уходит по диагонали sourceVideo.style.transform = "translate(-100%, -100%)" sourceVideo.style.opacity = "0" break case "spiral": // Эффект спирали: исходное видео вращается и уменьшается sourceVideo.style.transform = "rotate(720deg) scale(0)" sourceVideo.style.opacity = "0" break case "fold": // Эффект складывания: исходное видео складывается как страница sourceVideo.style.transform = "perspective(500px) rotateX(90deg)" sourceVideo.style.opacity = "0" break case "wave": // Эффект волны: исходное видео искажается волной и исчезает sourceVideo.style.transform = "scale(1.5)" sourceVideo.style.filter = "wave(16, 50%, 50%)" sourceVideo.style.opacity = "0" break case "shutter": // Эффект шторки: исходное видео закрывается шторкой sourceVideo.style.clipPath = "inset(0 50% 0 50%)" sourceVideo.style.opacity = "0" break case "bounce": // Эффект отскока: исходное видео уменьшается с эффектом пружины sourceVideo.style.transform = "scale(0)" sourceVideo.style.opacity = "0" sourceVideo.style.transition = "all 1s cubic-bezier(0.68, -0.55, 0.265, 1.55)" break case "swirl": // Эффект вихря: исходное видео вращается быстрее и исчезает sourceVideo.style.transform = "rotate(1080deg) scale(0)" sourceVideo.style.opacity = "0" sourceVideo.style.transition = "all 1s cubic-bezier(0.4, 0, 0.2, 1)" break case "dissolve": // Эффект растворения: исходное и целевое видео смешиваются sourceVideo.style.mixBlendMode = "multiply" targetVideo.style.mixBlendMode = "screen" sourceVideo.style.opacity = "0" break // 3D эффекты case "cube-3d": // Эффект 3D куба: вращение как грани куба sourceVideo.style.transform = "perspective(1000px) rotateY(-90deg) translateZ(200px)" sourceVideo.style.opacity = "0" targetVideo.style.transform = "perspective(1000px) rotateY(0deg) translateZ(0)" break case "page-turn": // Эффект переворота страницы sourceVideo.style.transform = "perspective(1000px) rotateY(-180deg)" sourceVideo.style.transformOrigin = "right center" sourceVideo.style.opacity = "0" break case "shatter": // Эффект разбития стекла sourceVideo.style.transform = "scale(1.2)" sourceVideo.style.filter = "blur(4px)" sourceVideo.style.opacity = "0" break // Дополнительные эффекты из JSON case "wipe-horizontal": sourceVideo.style.clipPath = "inset(0 100% 0 0)" break case "wipe-vertical": sourceVideo.style.clipPath = "inset(100% 0 0 0)" break case "wipe-diagonal": sourceVideo.style.clipPath = "polygon(0 0, 0 0, 0 100%, 0 100%)" break case "radial-wipe": sourceVideo.style.clipPath = "circle(0% at 50% 50%)" break case "ripple": sourceVideo.style.filter = "blur(8px)" sourceVideo.style.transform = "scale(1.1)" sourceVideo.style.opacity = "0" break case "pixelize": sourceVideo.style.imageRendering = "pixelated" sourceVideo.style.transform = "scale(0.1) scale(10)" sourceVideo.style.opacity = "0" break case "morph": sourceVideo.style.filter = "blur(10px)" sourceVideo.style.transform = "scale(0.8)" sourceVideo.style.opacity = "0" break case "glitch": sourceVideo.style.filter = "hue-rotate(90deg) saturate(2)" sourceVideo.style.transform = "translateX(5px) translateY(-5px)" sourceVideo.style.opacity = "0" break case "kaleidoscope": sourceVideo.style.transform = "rotate(180deg) scale(0.5)" sourceVideo.style.filter = "hue-rotate(180deg)" sourceVideo.style.opacity = "0" break case "burn": sourceVideo.style.filter = "brightness(2) contrast(2) sepia(1)" sourceVideo.style.opacity = "0" break case "blinds": sourceVideo.style.clipPath = "repeating-linear-gradient(0deg, transparent 0px, transparent 10px, black 10px, black 20px)" sourceVideo.style.opacity = "0" break case "iris": sourceVideo.style.clipPath = "circle(0% at 50% 50%)" targetVideo.style.clipPath = "circle(100% at 50% 50%)" break case "zoom-blur": sourceVideo.style.filter = "blur(20px)" sourceVideo.style.transform = "scale(2)" sourceVideo.style.opacity = "0" break case "tv-static": sourceVideo.style.filter = "contrast(100) grayscale(1)" sourceVideo.style.opacity = "0" break default: // Неизвестный тип перехода, используем fade по умолчанию sourceVideo.style.opacity = "0" break } // Запускаем воспроизведение целевого видео void targetVideo.play() // Устанавливаем таймер для повторения перехода через 2 секунды loopTimeoutRef.current = setTimeout(() => { if (isHovering) { startTransition() // Повторяем переход, если курсор все еще наведен } }, 2000) }, 1000) }, [isHovering, transitionType, isError]) // Убираем resetVideos из зависимостей /** * Эффект для инициализации видео и обработки ошибок */ useEffect(() => { if (!sourceVideoRef.current || !targetVideoRef.current) { console.warn(`🎬 [TransitionPreview] Видео элементы не найдены для ${transitionType}`) return } const sourceVideoElement = sourceVideoRef.current const targetVideoElement = targetVideoRef.current // Функция обработки ошибок загрузки видео const handleError = (e: Event) => { const target = e.target as HTMLVideoElement const errorInfo = { transition: transitionType, src: target.src, error: target.error, networkState: target.networkState, readyState: target.readyState, } console.error("🎬 [TransitionPreview] Ошибка загрузки видео:", errorInfo) // Дополнительная информация об ошибке if (target.error) { switch (target.error.code) { case target.error.MEDIA_ERR_ABORTED: console.error("Загрузка видео была прервана") break case target.error.MEDIA_ERR_NETWORK: console.error("Сетевая ошибка при загрузке видео") break case target.error.MEDIA_ERR_DECODE: console.error("Ошибка декодирования видео") break case target.error.MEDIA_ERR_SRC_NOT_SUPPORTED: console.error("Формат видео не поддерживается или файл не найден") break default: console.error(`Неизвестная ошибка видео: код ${target.error.code}`) break } } setIsError(true) } // Функция обработки успешной загрузки const handleLoadedData = () => { // Видео успешно загружено } // Добавляем обработчики событий для обоих видео sourceVideoElement.addEventListener("error", handleError) targetVideoElement.addEventListener("error", handleError) sourceVideoElement.addEventListener("loadeddata", handleLoadedData) targetVideoElement.addEventListener("loadeddata", handleLoadedData) // Сбрасываем видео к начальному состоянию, но НЕ запускаем автоматически resetVideos() // Устанавливаем первый кадр как превью sourceVideoElement.currentTime = 0 targetVideoElement.currentTime = 0 // Функция очистки при размонтировании компонента return () => { // Удаляем обработчики событий sourceVideoElement.removeEventListener("error", handleError) targetVideoElement.removeEventListener("error", handleError) sourceVideoElement.removeEventListener("loadeddata", handleLoadedData) targetVideoElement.removeEventListener("loadeddata", handleLoadedData) // Очищаем таймеры if (transitionTimeoutRef.current) clearTimeout(transitionTimeoutRef.current) if (loopTimeoutRef.current) clearTimeout(loopTimeoutRef.current) } }, []) // Убираем resetVideos из зависимостей /** * Эффект для управления переходами при наведении */ useEffect(() => { if (isHovering) { // Если курсор наведен - запускаем переход startTransition() } else { // Если курсор не наведен - сбрасываем к исходному состоянию resetVideos() // Останавливаем все видео и показываем первый кадр if (sourceVideoRef.current) { sourceVideoRef.current.pause() sourceVideoRef.current.currentTime = 0 } if (targetVideoRef.current) { targetVideoRef.current.pause() targetVideoRef.current.currentTime = 0 } // Очищаем таймеры if (transitionTimeoutRef.current) clearTimeout(transitionTimeoutRef.current) if (loopTimeoutRef.current) clearTimeout(loopTimeoutRef.current) } }, [isHovering]) // Убираем функции из зависимостей return (
{/* Контейнер превью перехода */}
setIsHovering(true)} onMouseLeave={() => setIsHovering(false)} onClick={onClick} > {/* Индикаторы сложности и категории */} {currentTransition && ( <> {/* Индикатор сложности */} {/*
{complexityIndicator.label[0]}
*/} {/* Индикатор категории */}
{categoryIndicator}
{/* Индикатор длительности */} {currentTransition.duration && (
{currentTransition.duration.default.toFixed(1)}s
)} )} {isError ? ( // Отображаем сообщение об ошибке, если видео не загрузилось
{t("timeline.player.videoLoadError")}
) : ( // Контейнер для видео с переходом
{/* Исходное видео (видимое в начале) */}
)}
{/* Название перехода */}
{currentTransition?.labels?.ru || t(`transitions.types.${transitionType}`, transitionType)}
) }