Истории, обрезка

This commit is contained in:
Халимов Рустам
2026-04-03 23:28:00 +03:00
parent 182bbe2ad8
commit b49ff47762

View File

@@ -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>
);
}