Истории, обрезка
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Camera, Type, Check, Palette, Smile, Brush, Filter, RotateCcw, Eye, MoreVertical, Globe, Users, Lock, ChevronLeft, Trash2, Scissors, Music, Eraser, Minus, Plus, Maximize, Trash, SlidersHorizontal, Sun, Contrast, Droplets, Wind, Pipette, Type as FontIcon, AlignCenter } from 'lucide-react';
|
||||
import { X, Camera, Type, Check, Palette, Smile, Brush, Filter, RotateCcw, Eye, MoreVertical, Globe, Users, Lock, ChevronLeft, ChevronRight, Trash2, Scissors, Music, Eraser, Minus, Plus, Maximize, Trash, SlidersHorizontal, Sun, Contrast, Droplets, Wind, Pipette, Type as FontIcon, AlignCenter } from 'lucide-react';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { ChatApi } from '../../../chats/infrastructure/chatApi';
|
||||
import { StoryApi } from '../../infrastructure/storyApi';
|
||||
@@ -51,18 +51,21 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
const [videoDuration, setVideoDuration] = useState(0);
|
||||
const [tool, setTool] = useState<'none' | 'crop' | 'brush' | 'stickers' | 'filters' | 'privacy' | 'text' | 'trim'>('none');
|
||||
const [paused, setPaused] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const editorVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const trimVideoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
const [textObjects, setTextObjects] = useState<TextObject[]>([]);
|
||||
const [selectedTextId, setSelectedTextId] = useState<number | null>(null);
|
||||
const [stickers, setStickers] = useState<Array<{ id: number, emoji: string, x: number, y: number, scale: number }>>([]);
|
||||
const [selectedStickerId, setSelectedStickerId] = useState<number | null>(null);
|
||||
const [currentPlayTime, setCurrentPlayTime] = useState(0);
|
||||
|
||||
const [brushColor, setBrushColor] = useState('#ffffff');
|
||||
const [brushSize, setBrushSize] = useState(8);
|
||||
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<any>(null);
|
||||
const [adjustments, setAdjustments] = useState({ brightness: 100, contrast: 100, saturate: 100, sepia: 0, grayscale: 0 });
|
||||
@@ -70,8 +73,24 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const isDrawingRef = useRef(false);
|
||||
|
||||
// Wheel Zoom Implementation
|
||||
useEffect(() => {
|
||||
const el = timelineRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1; // Smaller increments
|
||||
setZoom(prev => Math.max(1, Math.min(20, prev + delta)));
|
||||
};
|
||||
|
||||
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', handleWheel);
|
||||
}, [tool]);
|
||||
|
||||
// Scroll Lock implementation
|
||||
useEffect(() => {
|
||||
const originalStyle = window.getComputedStyle(document.body).overflow;
|
||||
@@ -81,6 +100,32 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Smooth Playback Loop for Timeline
|
||||
useEffect(() => {
|
||||
let rafId: number;
|
||||
const update = () => {
|
||||
const v = trimVideoRef.current;
|
||||
if (v && !v.paused && tool === 'trim') {
|
||||
const cur = v.currentTime;
|
||||
setCurrentPlayTime(cur);
|
||||
|
||||
// Correct boundary check: only pause at the end.
|
||||
if (cur >= endTime) {
|
||||
v.pause();
|
||||
v.currentTime = startTime;
|
||||
setPaused(true);
|
||||
setCurrentPlayTime(startTime);
|
||||
} else if (cur < startTime - 0.1) {
|
||||
v.currentTime = startTime;
|
||||
setCurrentPlayTime(startTime);
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(update);
|
||||
};
|
||||
rafId = requestAnimationFrame(update);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [tool, startTime, endTime]);
|
||||
|
||||
const TARGET_W = 1080;
|
||||
const TARGET_H = 1920;
|
||||
const SCALE_FACTOR = TARGET_W / 400;
|
||||
@@ -98,14 +143,50 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
if (file.type.startsWith('video/')) {
|
||||
const v = document.createElement('video');
|
||||
v.src = url;
|
||||
v.onloadedmetadata = () => {
|
||||
v.onloadedmetadata = async () => {
|
||||
setVideoDuration(v.duration);
|
||||
setStartTime(0);
|
||||
setEndTime(v.duration);
|
||||
setZoom(1);
|
||||
setCurrentPlayTime(0);
|
||||
setThumbnails([]);
|
||||
|
||||
if (v.duration > 120) {
|
||||
setTool('trim');
|
||||
} else {
|
||||
setTool('none');
|
||||
}
|
||||
|
||||
// Generate thumbnails for storyboard
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = 160;
|
||||
canvas.height = 90;
|
||||
const thumbs: string[] = [];
|
||||
const count = v.duration > 1800 ? 5 : (v.duration > 300 ? 8 : 10);
|
||||
for (let i = 0; i < count; i++) {
|
||||
v.currentTime = (v.duration / count) * i;
|
||||
await new Promise(r => {
|
||||
const onSeek = () => {
|
||||
v.removeEventListener('seeked', onSeek);
|
||||
r(null);
|
||||
};
|
||||
v.addEventListener('seeked', onSeek);
|
||||
setTimeout(() => {
|
||||
v.removeEventListener('seeked', onSeek);
|
||||
r(null);
|
||||
}, 1200); // Increased timeout for long videos
|
||||
});
|
||||
ctx?.drawImage(v, 0, 0, 160, 90);
|
||||
thumbs.push(canvas.toDataURL('image/jpeg', 0.4)); // Lower JPEG quality to save memory
|
||||
}
|
||||
setThumbnails(thumbs);
|
||||
setPaused(true); // Don't auto-play
|
||||
setCurrentPlayTime(0);
|
||||
if (v.duration > 120) {
|
||||
if (editorVideoRef.current) editorVideoRef.current.currentTime = 0;
|
||||
if (trimVideoRef.current) trimVideoRef.current.currentTime = 0;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
setTool('crop');
|
||||
@@ -118,6 +199,12 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
setCroppedPreview(null);
|
||||
setCroppedAreaPixels(null);
|
||||
setTool('none');
|
||||
setStartTime(0);
|
||||
setEndTime(0);
|
||||
setVideoDuration(0);
|
||||
setCurrentPlayTime(0);
|
||||
setZoom(1);
|
||||
setThumbnails([]);
|
||||
};
|
||||
|
||||
const addText = () => {
|
||||
@@ -275,6 +362,8 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-[200] bg-[#000] flex flex-col font-body text-white overflow-hidden selection:bg-primary/20">
|
||||
<style>{`
|
||||
@import url('https://fonts.googleapis.com/css2?family=Dancing+Script:wght@700&family=Playfair+Display:ital,wght@1,900&family=Fira+Code:wght@700&family=Archivo+Black&display=swap');
|
||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
`}</style>
|
||||
|
||||
<header className="h-20 px-8 flex items-center justify-between border-b border-white/5 bg-[#0a0a0a] z-50 shadow-2xl">
|
||||
@@ -311,25 +400,83 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
{(croppedPreview || mediaPreview) && (
|
||||
<div className="absolute inset-0" style={{ filter: getFilterCss() }}>
|
||||
{mediaFile?.type.startsWith('video/') ? (
|
||||
<div className="w-full h-full flex items-center justify-center" style={{ background: bgColor }}>
|
||||
<div className="w-full h-full flex items-center justify-center relative" style={{ background: bgColor }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
ref={editorVideoRef}
|
||||
src={mediaPreview!}
|
||||
className="w-full h-full object-contain"
|
||||
autoPlay={tool !== 'trim'}
|
||||
autoPlay={false}
|
||||
muted={isMuted}
|
||||
loop={tool !== 'trim'}
|
||||
onTimeUpdate={() => {
|
||||
if (tool === 'trim' && videoRef.current) {
|
||||
if (videoRef.current.currentTime > endTime) {
|
||||
videoRef.current.currentTime = startTime;
|
||||
}
|
||||
if (videoRef.current.currentTime < startTime) {
|
||||
videoRef.current.currentTime = startTime;
|
||||
}
|
||||
}
|
||||
}}
|
||||
const cur = editorVideoRef.current?.currentTime || 0;
|
||||
setCurrentPlayTime(cur);
|
||||
if (editorVideoRef.current) {
|
||||
if (cur >= endTime) {
|
||||
editorVideoRef.current.pause();
|
||||
editorVideoRef.current.currentTime = startTime;
|
||||
setPaused(true);
|
||||
setCurrentPlayTime(startTime);
|
||||
} else if (cur < startTime - 0.1) {
|
||||
editorVideoRef.current.currentTime = startTime;
|
||||
setCurrentPlayTime(startTime);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPlay={() => setPaused(false)}
|
||||
onPause={() => setPaused(true)}
|
||||
/>
|
||||
|
||||
<div
|
||||
onClick={() => {
|
||||
const v = editorVideoRef.current;
|
||||
if (v) {
|
||||
if (v.currentTime < startTime || v.currentTime > endTime) {
|
||||
v.currentTime = startTime;
|
||||
}
|
||||
if (v.paused) {
|
||||
v.play().catch(() => {});
|
||||
} else {
|
||||
v.pause();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="absolute inset-0 z-20 flex items-center justify-center cursor-pointer group"
|
||||
>
|
||||
{paused && (
|
||||
<motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="w-20 h-20 rounded-full bg-black/40 backdrop-blur-xl flex items-center justify-center border border-white/20 group-hover:bg-primary/20 group-hover:border-primary/40 transition-all">
|
||||
<span className="material-symbols-outlined text-4xl text-white">play_arrow</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls (Stop/Seek Placeholder) */}
|
||||
<div className="absolute bottom-10 left-0 right-0 z-30 px-10 pointer-events-none">
|
||||
<div className="flex items-center gap-4 pointer-events-auto">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const v = editorVideoRef.current;
|
||||
if (v) {
|
||||
v.pause();
|
||||
v.currentTime = startTime;
|
||||
setPaused(true);
|
||||
setCurrentPlayTime(startTime);
|
||||
}
|
||||
}}
|
||||
className="w-10 h-10 rounded-xl bg-black/60 backdrop-blur-md flex items-center justify-center text-white border border-white/10 hover:bg-red-500/20 hover:border-red-500/40 transition-all"
|
||||
title="Остановить"
|
||||
>
|
||||
<span className="material-symbols-outlined">stop</span>
|
||||
</button>
|
||||
<div className="flex-1 h-1.5 bg-white/10 rounded-full overflow-hidden backdrop-blur-md border border-white/5 font-mono">
|
||||
<motion.div
|
||||
className="h-full bg-primary"
|
||||
animate={{ width: `${(( (editorVideoRef.current?.currentTime || 0) - startTime) / (endTime - startTime)) * 100}%` }}
|
||||
transition={{ duration: 0.1, ease: 'linear' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img src={croppedPreview || mediaPreview!} className="w-full h-full object-cover" alt="base" />
|
||||
@@ -366,12 +513,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tool === 'crop' && mediaPreview && (
|
||||
<div className="absolute inset-0 z-[100] bg-black">
|
||||
<Cropper image={mediaPreview} crop={crop} zoom={zoom} rotation={rotation} aspect={9/16} onCropChange={setCrop} onZoomChange={setZoom} onCropComplete={(_, pixels) => setCroppedAreaPixels(pixels)} />
|
||||
<button onClick={handleCropDone} className="absolute bottom-10 left-1/2 -translate-x-1/2 px-10 py-4 bg-primary text-white rounded-full font-black text-xs uppercase z-[110] shadow-2xl hover:scale-105 active:scale-95 transition-all">Готово</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile interface components */}
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleMediaSelect} />
|
||||
</section>
|
||||
@@ -380,13 +522,12 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
<div className="p-10 space-y-12 pb-24">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ id: 'media', icon: <Camera />, label: 'Медиа', action: () => fileInputRef.current?.click() },
|
||||
{ id: 'text', icon: <Type />, label: 'Текст', action: addText },
|
||||
{ id: 'stickers', icon: <Smile />, label: 'Стикеры', active: tool === 'stickers' },
|
||||
{ id: 'brush', icon: <Brush />, label: 'Кисти', active: tool === 'brush' },
|
||||
{ id: 'filters', icon: <SlidersHorizontal />, label: 'Коррекция', active: tool === 'filters' },
|
||||
].map(item => (
|
||||
<button key={item.id} onClick={() => { if(item.action) item.action(); setTool(item.id as any); }} className={`flex flex-col items-start justify-between h-32 p-7 rounded-[2.5rem] border transition-all duration-500 ${tool === item.id ? 'bg-primary/10 border-primary/40 text-white shadow-[0_15px_40px_-10px_rgba(var(--primary-rgb),0.3)]' : 'bg-[#121212] border-white/5 text-zinc-500 hover:text-white hover:border-white/10'}`}>
|
||||
<button key={item.id} onClick={() => { if(item.id === 'text') { if(item.action) item.action(); setTool('text'); } else { setTool(item.id as any); } }} className={`flex flex-col items-start justify-between h-32 p-7 rounded-[2.5rem] border transition-all duration-500 ${tool === item.id ? 'bg-primary/10 border-primary/40 text-white shadow-[0_15px_40px_-10px_rgba(var(--primary-rgb),0.3)]' : 'bg-[#121212] border-white/5 text-zinc-500 hover:text-white hover:border-white/10'}`}>
|
||||
<div className={tool === item.id ? 'text-primary' : 'text-zinc-700'}>{item.icon}</div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">{item.label}</span>
|
||||
</button>
|
||||
@@ -464,105 +605,6 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{tool === 'trim' && (
|
||||
<motion.div key="trim-tool" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -20 }} className="p-8 rounded-[2.5rem] bg-[#121212] border border-white/5 space-y-8 shadow-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-white text-[10px] font-black uppercase tracking-widest flex items-center gap-2">
|
||||
<Scissors size={14} className="text-primary" />
|
||||
Обрезка видео
|
||||
</h3>
|
||||
<div className="text-[10px] font-bold text-zinc-500 bg-white/5 px-2 py-1 rounded">
|
||||
{(endTime - startTime).toFixed(1)}с / {videoDuration.toFixed(1)}с
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{videoDuration > 120 && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-[10px] text-red-400 font-bold uppercase tracking-wider leading-relaxed">
|
||||
<span className="material-symbols-outlined text-sm">warning</span>
|
||||
Видео слишком длинное! Обязательно обрежьте до 2 минут (120с).
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-[9px] uppercase tracking-widest text-zinc-500 font-black">
|
||||
<span>Начало</span>
|
||||
<span className="text-white">{startTime.toFixed(1)}с</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(0, endTime - 1)}
|
||||
step={0.1}
|
||||
value={startTime}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setStartTime(val);
|
||||
if (videoRef.current) videoRef.current.currentTime = val;
|
||||
}}
|
||||
className="w-full h-1 bg-white/5 rounded-full appearance-none accent-primary cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-[9px] uppercase tracking-widest text-zinc-500 font-black">
|
||||
<span>Конец</span>
|
||||
<span className="text-white">{endTime.toFixed(1)}с</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={startTime + 1}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
value={endTime}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setEndTime(val);
|
||||
if (videoRef.current) videoRef.current.currentTime = val;
|
||||
}}
|
||||
className="w-full h-1 bg-white/5 rounded-full appearance-none accent-primary cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-6 border-t border-white/5">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (videoRef.current) {
|
||||
if (videoRef.current.paused) videoRef.current.play();
|
||||
else videoRef.current.pause();
|
||||
setPaused(videoRef.current.paused);
|
||||
}
|
||||
}}
|
||||
className="w-14 h-14 rounded-2xl bg-white/5 hover:bg-white/10 flex items-center justify-center text-white transition-all border border-white/5"
|
||||
>
|
||||
{paused ? <Plus size={24} className="rotate-45" /> : <Minus size={24} />} {/* Using Plus/Minus as fallback icons since play/pause might be missing or I'll use standard symbols */}
|
||||
<span className="material-symbols-outlined">{paused ? 'play_arrow' : 'pause'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (videoRef.current) videoRef.current.currentTime = startTime; }}
|
||||
className="text-[10px] uppercase font-black tracking-widest text-zinc-500 hover:text-white"
|
||||
>
|
||||
В начало
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (videoDuration > 120 && (endTime - startTime) > 120) {
|
||||
return;
|
||||
}
|
||||
setTool('none');
|
||||
}}
|
||||
disabled={videoDuration > 120 && (endTime - startTime) > 120}
|
||||
className="px-8 py-4 bg-white text-black rounded-2xl text-[10px] font-black uppercase tracking-widest hover:scale-105 active:scale-95 transition-all shadow-xl shadow-white/10 disabled:opacity-30"
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!mediaPreview && tool === 'none' && (
|
||||
<motion.div key="bg-tool" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="p-10 rounded-[2.5rem] bg-[#121212] flex flex-col items-center gap-8 border border-white/5 shadow-2xl">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
@@ -577,11 +619,346 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="pt-10 border-t border-white/5" />
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{tool === 'trim' && mediaPreview && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: '100%' }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed inset-0 z-[300] bg-black flex flex-col items-center overflow-y-auto"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="w-full min-h-[80px] px-8 flex items-center justify-between border-b border-white/5 bg-[#0a0a0a] sticky top-0 z-50">
|
||||
<button onClick={() => {
|
||||
setTool('none');
|
||||
const v = trimVideoRef.current;
|
||||
const ev = editorVideoRef.current;
|
||||
if (v) v.pause();
|
||||
if (ev) {
|
||||
ev.pause();
|
||||
ev.currentTime = startTime;
|
||||
setPaused(true);
|
||||
setCurrentPlayTime(startTime);
|
||||
}
|
||||
}} className="w-12 h-12 rounded-full hover:bg-white/5 flex items-center justify-center text-zinc-400 transition-colors">
|
||||
<ChevronLeft size={28} />
|
||||
</button>
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em] text-zinc-200">Обрезка видео</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTool('none');
|
||||
const v = trimVideoRef.current;
|
||||
const ev = editorVideoRef.current;
|
||||
if (v) v.pause();
|
||||
if (ev) {
|
||||
ev.pause();
|
||||
ev.currentTime = startTime;
|
||||
setPaused(true);
|
||||
setCurrentPlayTime(startTime);
|
||||
}
|
||||
}}
|
||||
className="px-6 py-2 rounded-xl bg-primary text-on-primary text-[10px] font-black uppercase tracking-widest hover:scale-105 active:scale-95 transition-all"
|
||||
>
|
||||
Готово
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full max-w-4xl flex flex-col items-center p-6 md:p-12 gap-10">
|
||||
{/* Floating Timer - Moved Above */}
|
||||
<div className="bg-black/40 backdrop-blur-3xl px-6 py-3 rounded-2xl border border-white/5 shadow-2xl mb-2">
|
||||
<span className="text-sm font-mono font-black tracking-widest text-primary italic">
|
||||
{new Date(currentPlayTime * 1000).toISOString().substr(14, 5)} / {new Date(videoDuration * 1000).toISOString().substr(14, 5)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Video Preview */}
|
||||
<div className="relative w-full max-w-[320px] aspect-[9/16] bg-[#070707] rounded-[3rem] overflow-hidden shadow-[0_40px_80px_rgba(0,0,0,0.8)] border-[6px] border-white/5 group">
|
||||
<video
|
||||
ref={trimVideoRef}
|
||||
src={mediaPreview!}
|
||||
className="w-full h-full object-contain"
|
||||
muted={isMuted}
|
||||
onTimeUpdate={() => {
|
||||
const cur = trimVideoRef.current?.currentTime || 0;
|
||||
setCurrentPlayTime(cur);
|
||||
if (trimVideoRef.current) {
|
||||
if (cur >= endTime) {
|
||||
trimVideoRef.current.pause();
|
||||
trimVideoRef.current.currentTime = startTime;
|
||||
setPaused(true);
|
||||
setCurrentPlayTime(startTime);
|
||||
} else if (cur < startTime - 0.1) {
|
||||
trimVideoRef.current.currentTime = startTime;
|
||||
setCurrentPlayTime(startTime);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPlay={() => setPaused(false)}
|
||||
onPause={() => setPaused(true)}
|
||||
/>
|
||||
{/* Play/Pause Overlay on tap */}
|
||||
<div onClick={() => {
|
||||
const v = trimVideoRef.current;
|
||||
if(v) {
|
||||
if (v.currentTime < startTime || v.currentTime > endTime) {
|
||||
v.currentTime = startTime;
|
||||
}
|
||||
if(v.paused) {
|
||||
v.play().catch(() => {});
|
||||
} else {
|
||||
v.pause();
|
||||
}
|
||||
}
|
||||
}} className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-black/20 cursor-pointer">
|
||||
<div className="w-20 h-20 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center border border-white/20">
|
||||
<span className="material-symbols-outlined text-4xl">{paused ? 'play_arrow' : 'pause'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-4">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-pulse" />
|
||||
<span className="text-[10px] font-black uppercase text-zinc-500 tracking-widest">Колесико мыши для зума ({zoom.toFixed(1)}x)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-[9px] font-black uppercase text-zinc-600 tracking-widest">{new Date(0 * 1000).toISOString().substr(14, 5)}</span>
|
||||
<div className="px-4 py-1.5 rounded-lg bg-primary/10 border border-primary/20">
|
||||
<span className="text-[10px] font-black italic text-primary uppercase">{(endTime - startTime).toFixed(1)} сек выбрано</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-black uppercase text-zinc-600 tracking-widest">{new Date(videoDuration * 1000).toISOString().substr(14, 5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-3">
|
||||
{/* Overview Track (Mini-map) */}
|
||||
<div className="relative h-4 bg-white/5 rounded-full overflow-hidden border border-white/10 group/mini">
|
||||
<div className="absolute inset-0 flex opacity-20 pointer-events-none">
|
||||
{thumbnails.map((t, i) => (
|
||||
<img key={i} src={t} className="flex-1 h-full object-cover grayscale" alt="mini" />
|
||||
))}
|
||||
</div>
|
||||
{/* Selection UI on Mini-map */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-primary/30 border-x border-primary/50 cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.parentElement?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const time = x * videoDuration;
|
||||
(timelineRef.current as any)._dragging = 'range';
|
||||
(timelineRef.current as any)._offsetTime = time - startTime;
|
||||
}}
|
||||
style={{
|
||||
left: `${(startTime / videoDuration) * 100}%`,
|
||||
width: `${((endTime - startTime) / videoDuration) * 100}%`
|
||||
}}
|
||||
/>
|
||||
{/* Mini Playhead */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-white z-10"
|
||||
style={{ left: `${(currentPlayTime / videoDuration) * 100}%` }}
|
||||
/>
|
||||
{/* Interaction Layer */}
|
||||
<div
|
||||
className="absolute inset-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const time = x * videoDuration;
|
||||
if (trimVideoRef.current) trimVideoRef.current.currentTime = time;
|
||||
setCurrentPlayTime(time);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time Ruler Placeholder */}
|
||||
<div className="flex justify-between px-1 text-[8px] font-black font-mono text-zinc-600 uppercase tracking-tighter">
|
||||
{[0, 0.25, 0.5, 0.75, 1].map(p => (
|
||||
<span key={p}>{new Date(videoDuration * p * 1000).toISOString().substr(14, 5)}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Precision Track (Zoomable) */}
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className="relative w-full overflow-x-auto overflow-y-hidden no-scrollbar bg-[#121212] rounded-[1.5rem] border border-white/5 h-24 group/timeline"
|
||||
>
|
||||
<div
|
||||
className="relative h-full"
|
||||
style={{ width: `${zoom * 100}%`, minWidth: '100%' }}
|
||||
onMouseDown={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const time = x * videoDuration;
|
||||
|
||||
const startX = (startTime / videoDuration) * rect.width;
|
||||
const endX = (endTime / videoDuration) * rect.width;
|
||||
const mouseX = e.clientX - rect.left;
|
||||
|
||||
if (Math.abs(mouseX - startX) < 20) {
|
||||
(e.currentTarget as any)._dragging = 'start';
|
||||
} else if (Math.abs(mouseX - endX) < 20) {
|
||||
(e.currentTarget as any)._dragging = 'end';
|
||||
} else if (mouseX > startX && mouseX < endX) {
|
||||
(e.currentTarget as any)._dragging = 'range';
|
||||
(e.currentTarget as any)._offsetTime = time - startTime;
|
||||
} else {
|
||||
(e.currentTarget as any)._dragging = 'scrub';
|
||||
if (trimVideoRef.current) {
|
||||
trimVideoRef.current.currentTime = time;
|
||||
setCurrentPlayTime(time);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
const dragging = (e.currentTarget as any)._dragging;
|
||||
if (!dragging) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const time = Math.max(0, Math.min(videoDuration, x * videoDuration));
|
||||
|
||||
if (dragging === 'start') {
|
||||
if (time < endTime - 0.2) {
|
||||
setStartTime(time);
|
||||
if (trimVideoRef.current) {
|
||||
trimVideoRef.current.currentTime = time;
|
||||
setCurrentPlayTime(time);
|
||||
}
|
||||
}
|
||||
} else if (dragging === 'end') {
|
||||
if (time > startTime + 0.2) {
|
||||
setEndTime(time);
|
||||
if (trimVideoRef.current) {
|
||||
trimVideoRef.current.currentTime = time;
|
||||
setCurrentPlayTime(time);
|
||||
}
|
||||
}
|
||||
} else if (dragging === 'range') {
|
||||
const offset = (e.currentTarget as any)._offsetTime;
|
||||
const duration = endTime - startTime;
|
||||
let newStart = time - offset;
|
||||
if (newStart < 0) newStart = 0;
|
||||
if (newStart + duration > videoDuration) newStart = videoDuration - duration;
|
||||
setStartTime(newStart);
|
||||
setEndTime(newStart + duration);
|
||||
if (trimVideoRef.current) {
|
||||
trimVideoRef.current.currentTime = newStart;
|
||||
setCurrentPlayTime(newStart);
|
||||
}
|
||||
} else if (dragging === 'scrub') {
|
||||
if (trimVideoRef.current) {
|
||||
trimVideoRef.current.currentTime = time;
|
||||
setCurrentPlayTime(time);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseUp={(e) => (e.currentTarget as any)._dragging = null}
|
||||
onMouseLeave={(e) => (e.currentTarget as any)._dragging = null}
|
||||
>
|
||||
{/* Thumbnails Track */}
|
||||
<div className="absolute inset-0 flex">
|
||||
{thumbnails.map((t, i) => (
|
||||
<img key={i} src={t} className="flex-1 h-full object-cover opacity-20 filter grayscale" alt="frame" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selection Window */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 bg-primary/20 border-x-4 border-primary z-10 shadow-[0_0_40px_rgba(var(--primary-rgb),0.3)]"
|
||||
style={{
|
||||
left: `${(startTime / videoDuration) * 100}%`,
|
||||
right: `${100 - (endTime / videoDuration) * 100}%`
|
||||
}}
|
||||
>
|
||||
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-6 h-12 bg-primary rounded-full flex flex-col items-center justify-center gap-1 shadow-2xl cursor-ew-resize">
|
||||
<div className="w-0.5 h-4 bg-white/40 rounded-full" />
|
||||
</div>
|
||||
<div className="absolute -right-3 top-1/2 -translate-y-1/2 w-6 h-12 bg-primary rounded-full flex flex-col items-center justify-center gap-1 shadow-2xl cursor-ew-resize">
|
||||
<div className="w-0.5 h-4 bg-white/40 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playback Cursor */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-1 bg-white shadow-[0_0_20px_white] z-20 pointer-events-none"
|
||||
style={{ left: `${(currentPlayTime / videoDuration) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Player Controls */}
|
||||
<div className="flex items-center justify-center pt-8">
|
||||
<button
|
||||
onClick={() => {
|
||||
const v = trimVideoRef.current;
|
||||
if (v) {
|
||||
if (v.currentTime < startTime || v.currentTime > endTime) v.currentTime = startTime;
|
||||
if (v.paused) v.play().catch(() => {}); else v.pause();
|
||||
setPaused(v.paused);
|
||||
}
|
||||
}}
|
||||
className="w-24 h-24 rounded-[2rem] bg-primary flex items-center justify-center text-on-primary shadow-[0_0_50px_rgba(var(--primary-rgb),0.5)] hover:scale-105 active:scale-95 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-5xl text-white">{paused ? 'play_arrow' : 'pause'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 max-w-2xl mx-auto w-full">
|
||||
<div className="bg-[#121212] p-8 rounded-[2.5rem] border border-white/5 flex flex-col gap-2 shadow-xl">
|
||||
<span className="text-[11px] font-black uppercase text-zinc-600 tracking-[0.2em]">Начало</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-3xl font-black italic tracking-tighter text-white font-mono">
|
||||
{new Date(startTime * 1000).toISOString().substr(14, 5)}<span className="text-primary text-xl">.{(startTime % 1).toFixed(1).substr(2)}</span>
|
||||
</span>
|
||||
<ChevronLeft size={24} className="text-zinc-800 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-[#121212] p-8 rounded-[2.5rem] border border-white/5 flex flex-col gap-2 shadow-xl">
|
||||
<span className="text-[11px] font-black uppercase text-zinc-600 tracking-[0.2em]">Конец</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-3xl font-black italic tracking-tighter text-white font-mono">
|
||||
{new Date(endTime * 1000).toISOString().substr(14, 5)}<span className="text-primary text-xl">.{(endTime % 1).toFixed(1).substr(2)}</span>
|
||||
</span>
|
||||
<ChevronRight size={24} className="text-zinc-800 opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 flex justify-center pb-12">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (videoDuration > 120 && (endTime - startTime) > 120) return;
|
||||
setTool('none');
|
||||
if (trimVideoRef.current) trimVideoRef.current.pause();
|
||||
if (editorVideoRef.current) {
|
||||
editorVideoRef.current.pause();
|
||||
editorVideoRef.current.currentTime = startTime;
|
||||
setPaused(true);
|
||||
setCurrentPlayTime(startTime);
|
||||
}
|
||||
}}
|
||||
disabled={videoDuration > 120 && (endTime - startTime) > 120}
|
||||
className="px-24 py-7 bg-white text-black rounded-full font-black uppercase tracking-[0.2em] text-sm hover:scale-105 active:scale-95 disabled:opacity-30 transition-all shadow-2xl"
|
||||
>
|
||||
{videoDuration > 120 && (endTime - startTime) > 120 ? 'Слишком длинное' : 'Применить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user