diff --git a/src/components/ComparisonPreview.tsx b/src/components/ComparisonPreview.tsx index 2fe4a89e..f0a1a8a4 100644 --- a/src/components/ComparisonPreview.tsx +++ b/src/components/ComparisonPreview.tsx @@ -1,7 +1,7 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ "use client"; -import { useEffect, useRef, useState, useCallback, RefObject } from "react"; +import { useEffect, useRef, useState, useCallback, useMemo, RefObject } from "react"; import { EditRecipe } from "@/lib/types"; import { getPresetById } from "@/lib/presets"; import { cn } from "@/lib/utils"; @@ -19,6 +19,15 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) { const [isDragging, setIsDragging] = useState(false); const containerRef = useRef(null); + const aspectStyle = useMemo(() => { + if (!recipe) return undefined; + const preset = recipe.preset === "custom" + ? { width: recipe.customWidth, height: recipe.customHeight } + : getPresetById(recipe.preset); + if (!preset) return undefined; + return { aspectRatio: `${preset.width} / ${preset.height}` }; + }, [recipe]); + // Calculate overlay for the right (reframed) side const overlay = (() => { if (!recipe) return null; @@ -166,7 +175,8 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) { return (
diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx index f9f2127e..c33c04c6 100644 --- a/src/components/FileUpload.tsx +++ b/src/components/FileUpload.tsx @@ -12,6 +12,7 @@ interface Props { currentFile: File | null; fileError: string; duration: number; + isLoading?: boolean; } export default function FileUpload({ @@ -19,6 +20,7 @@ export default function FileUpload({ currentFile, fileError, duration, + isLoading = false, }: Props) { const inputRef = useRef(null); @@ -259,6 +261,24 @@ export default function FileUpload({ />
); + + // ── Loading state ── + const LoadingState = () => ( +
+
+
+ +
+
+

+ Processing Video +

+

+ Extracting dimensions, aspect ratio, and metadata... +

+
+
+ ); return ( <> @@ -305,7 +325,7 @@ export default function FileUpload({ {warning}

)} - {currentFile ? : } + {isLoading ? : currentFile ? : }
); diff --git a/src/components/SubtitleControls.tsx b/src/components/SubtitleControls.tsx new file mode 100644 index 00000000..cec145c2 --- /dev/null +++ b/src/components/SubtitleControls.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { EditRecipe } from "@/lib/types"; +import { SubtitleItem } from "@/lib/subtitles"; +import { Upload, Trash2, Sliders, Type, Palette, Layout, Search, Play } from "lucide-react"; +import { useMemo, useState, useRef } from "react"; +import { getPresetById } from "@/lib/presets"; + +interface SubtitleControlsProps { + recipe: EditRecipe; + onChange: (patch: Partial) => void; + subtitleFile: File | null; + parsedSubtitles: SubtitleItem[] | null; + onSubtitleSelect: (file: File) => void; + onClearSubtitles: () => void; + onSeek: (time: number) => void; +} + +const FONTS = [ + { value: "Inter", label: "Inter" }, + { value: "Outfit", label: "Outfit" }, + { value: "Roboto", label: "Roboto" }, + { value: "Arial", label: "Arial (Standard)" }, + { value: "Courier New", label: "Courier New" }, + { value: "Times New Roman", label: "Times New Roman" }, + { value: "system-ui", label: "System UI" }, +]; + +const PRESET_COLORS = [ + { hex: "#ffffff", label: "White" }, + { hex: "#ffff00", label: "Yellow" }, + { hex: "#00ffff", label: "Cyan" }, + { hex: "#00ff00", label: "Green" }, + { hex: "#ff00ff", label: "Magenta" }, + { hex: "#000000", label: "Black" }, +]; + +const STYLES = [ + { value: "none", label: "Plain Text" }, + { value: "outline", label: "Black Outline" }, + { value: "box", label: "Semi-Transparent Box" }, + { value: "shadow", label: "Soft Shadow" }, +]; + +export default function SubtitleControls({ + recipe, + onChange, + subtitleFile, + parsedSubtitles, + onSubtitleSelect, + onClearSubtitles, + onSeek, +}: SubtitleControlsProps) { + const [searchQuery, setSearchQuery] = useState(""); + const fileInputRef = useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + onSubtitleSelect(file); + } + }; + + const filteredSubtitles = useMemo(() => { + if (!parsedSubtitles) return []; + if (!searchQuery.trim()) return parsedSubtitles; + const query = searchQuery.toLowerCase(); + return parsedSubtitles.filter((sub) => sub.text.toLowerCase().includes(query)); + }, [parsedSubtitles, searchQuery]); + + const formatTimestamp = (sec: number) => { + const min = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + const ms = Math.floor((sec % 1) * 1000); + return `${String(min).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(ms).padStart(3, "0")}`; + }; + + return ( +
+ {/* File Upload / Clear */} +
+ {!subtitleFile ? ( +
+ + +
+ ) : ( +
+
+

+ {subtitleFile.name} +

+

+ {parsedSubtitles?.length ?? 0} subtitle segments loaded +

+
+ +
+ )} +
+ + {subtitleFile && ( +
+ {/* Typography Settings */} +
+

+ + Typography +

+ + {/* Font Family */} +
+ + +
+ + {/* Font Size */} +
+
+ + + {recipe.subtitleSize}px + +
+
+ onChange({ subtitleSize: Number(e.target.value) })} + className="flex-1 accent-film-600 cursor-pointer" + /> +
+ {(["Small", "Medium", "Large"] as const).map((label, idx) => { + const size = idx === 0 ? 24 : idx === 1 ? 36 : 48; + const isActive = recipe.subtitleSize === size; + return ( + + ); + })} +
+
+
+
+ + {/* Color Settings */} +
+

+ + Colors +

+ + {/* Text Color */} +
+ +
+ onChange({ subtitleColor: e.target.value })} + className="w-9 h-7 rounded border border-[var(--border)] cursor-pointer shrink-0 bg-transparent" + /> + onChange({ subtitleColor: e.target.value })} + placeholder="#ffffff" + className="flex-1 px-2 py-1 text-xs rounded border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] font-mono focus:outline-none focus:ring-2 focus:ring-film-500" + aria-label="Subtitle Hex color input" + /> +
+ + {/* Curated color buttons */} +
+ {PRESET_COLORS.map((color) => ( + + ))} +
+
+
+ + {/* Style & Box Settings */} +
+

+ + Background & Readability +

+ + {/* Background type select */} +
+ +
+ {STYLES.map((style) => { + const isActive = recipe.subtitleBgType === style.value; + return ( + + ); + })} +
+
+ + {/* Background / outline color */} + {recipe.subtitleBgType !== "none" && ( +
+ +
+ onChange({ subtitleBgColor: e.target.value })} + className="w-9 h-7 rounded border border-[var(--border)] cursor-pointer shrink-0 bg-transparent" + /> + onChange({ subtitleBgColor: e.target.value })} + placeholder="#000000" + className="flex-1 px-2 py-1 text-xs rounded border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] font-mono focus:outline-none focus:ring-2 focus:ring-film-500" + aria-label="Subtitle Background Hex color input" + /> +
+
+ )} +
+ + {/* Subtitles Navigation list */} +
+
+

+ + Segments +

+ + {filteredSubtitles.length} of {parsedSubtitles?.length ?? 0} + +
+ + {/* Search Input */} +
+ + + + setSearchQuery(e.target.value)} + className="w-full pl-8 pr-2.5 py-1.5 text-xs rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] placeholder-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-film-500" + /> +
+ + {/* Subtitle segments list */} + {filteredSubtitles.length > 0 ? ( +
+ {filteredSubtitles.map((sub) => ( + + ))} +
+ ) : ( +
+ No matching captions found +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/TimelineEditor.tsx b/src/components/TimelineEditor.tsx new file mode 100644 index 00000000..799bee60 --- /dev/null +++ b/src/components/TimelineEditor.tsx @@ -0,0 +1,704 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useRef, useState, useCallback, useMemo } from "react"; +import { EditRecipe } from "@/lib/types"; +import { cn, formatDuration } from "@/lib/utils"; +import { GripVertical } from "lucide-react"; + +interface Thumbnail { + time: number; + dataUrl: string; +} + +interface TimelineEditorProps { + videoSrc: string | null; + duration: number; + currentTime: number; + recipe: EditRecipe; + onChange: (patch: Partial) => void; + onSeek: (time: number) => void; +} + +export default function TimelineEditor({ + videoSrc, + duration, + currentTime, + recipe, + onChange, + onSeek, +}: TimelineEditorProps) { + const [thumbnails, setThumbnails] = useState([]); + const [isGenerating, setIsGenerating] = useState(false); + const [progress, setProgress] = useState(0); + + const trackRef = useRef(null); + const draggingRef = useRef<"start" | "end" | "clip" | "playhead" | null>(null); + const dragStartOffsetRef = useRef(0); + const dragStartClipRef = useRef<{ start: number; end: number }>({ start: 0, end: 0 }); + + const lastRunIdRef = useRef(0); + const objectUrlsRef = useRef([]); + const offscreenVideoRef = useRef(null); + + const effectiveTrimEnd = recipe.trimEnd ?? duration; + const clipLength = effectiveTrimEnd - recipe.trimStart; + + // State for snapping guide + const [snapGuideTime, setSnapGuideTime] = useState(null); + + const revokeAllObjectUrls = useCallback(() => { + objectUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); + objectUrlsRef.current = []; + }, []); + + const cancelThumbnailRun = useCallback(() => { + lastRunIdRef.current += 1; + }, []); + + // Determine snap intervals + const snapStep = useMemo(() => { + if (duration <= 15) return 1; + if (duration <= 60) return 2; + if (duration <= 180) return 5; + return 10; + }, [duration]); + + // ── Thumbnail Generation ── + const generateThumbnails = useCallback(async () => { + if (!videoSrc || duration <= 0) return; + + const runId = ++lastRunIdRef.current; + setIsGenerating(true); + revokeAllObjectUrls(); + setThumbnails([]); + setProgress(0); + + const video = document.createElement("video"); + offscreenVideoRef.current = video; + + try { + video.src = videoSrc; + video.crossOrigin = "anonymous"; + video.muted = true; + video.preload = "auto"; + + await new Promise((resolve, reject) => { + video.onloadedmetadata = () => resolve(); + video.onerror = () => reject(new Error("Video load failed")); + video.load(); + }); + + if (lastRunIdRef.current !== runId) return; + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const thumbW = 160; + const thumbH = 90; + canvas.width = thumbW; + canvas.height = thumbH; + + // Calculate how many frames to extract to fit perfectly on screen + const times: number[] = []; + const interval = Math.max(0.5, duration / 12); // extract up to 12 frames + for (let t = 0; t <= duration; t += interval) { + times.push(Math.min(t, duration - 0.1)); + } + if ((times[times.length - 1] ?? 0) < duration - 0.2) { + times.push(duration - 0.1); + } + + const captured: Thumbnail[] = []; + + for (let i = 0; i < times.length; i++) { + if (lastRunIdRef.current !== runId) break; + + const time = times[i] ?? 0; + await new Promise((resolve) => { + const onSeeked = async () => { + video.removeEventListener("seeked", onSeeked); + + if (lastRunIdRef.current !== runId) { + resolve(); + return; + } + + ctx.drawImage(video, 0, 0, thumbW, thumbH); + + try { + const blob = await new Promise((blobResolve) => { + canvas.toBlob((b) => blobResolve(b), "image/jpeg", 0.6); + }); + if (blob && lastRunIdRef.current === runId) { + const url = URL.createObjectURL(blob); + objectUrlsRef.current.push(url); + captured.push({ time, dataUrl: url }); + setThumbnails([...captured]); + } + } catch (err) { + console.error("Failed to generate thumbnail blob", err); + } + + setProgress(Math.round(((i + 1) / times.length) * 100)); + resolve(); + }; + video.addEventListener("seeked", onSeeked); + video.currentTime = time; + }); + } + + if (lastRunIdRef.current === runId) { + setIsGenerating(false); + } + } finally { + video.src = ""; + if (offscreenVideoRef.current === video) { + offscreenVideoRef.current = null; + } + } + }, [videoSrc, duration, revokeAllObjectUrls]); + + useEffect(() => { + if (videoSrc && duration > 0) { + generateThumbnails(); + } + return () => { + cancelThumbnailRun(); + revokeAllObjectUrls(); + }; + }, [cancelThumbnailRun, generateThumbnails, revokeAllObjectUrls, videoSrc, duration]); + + // ── Drag & Drop Interactions ── + const xToSeconds = useCallback((clientX: number) => { + const track = trackRef.current; + if (!track || duration <= 0) return 0; + const { left, width } = track.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - left) / width)); + return ratio * duration; + }, [duration]); + + // Snapping calculations + const calculateSnap = useCallback((seconds: number, threshold = 0.25) => { + // Candidates for snapping: start (0), end (duration), and multiples of snapStep + const candidates: number[] = [0, duration]; + for (let t = snapStep; t < duration; t += snapStep) { + candidates.push(t); + } + + let closestTime = seconds; + let minDistance = Infinity; + + candidates.forEach((cand) => { + const dist = Math.abs(seconds - cand); + if (dist < minDistance && dist <= threshold) { + minDistance = dist; + closestTime = cand; + } + }); + + return { + time: closestTime, + snapped: minDistance !== Infinity && closestTime !== seconds, + }; + }, [duration, snapStep]); + + const handleDrag = useCallback((clientX: number) => { + if (!draggingRef.current || duration <= 0) return; + + const currentSeconds = xToSeconds(clientX); + + if (draggingRef.current === "playhead") { + const snap = calculateSnap(currentSeconds, 0.15); + onSeek(Math.max(0, Math.min(duration, snap.time))); + setSnapGuideTime(snap.snapped ? snap.time : null); + } else if (draggingRef.current === "start") { + const snap = calculateSnap(currentSeconds, 0.25); + const clamped = Math.max(0, Math.min(snap.time, effectiveTrimEnd - 0.1)); + onChange({ trimStart: parseFloat(clamped.toFixed(2)) }); + setSnapGuideTime(snap.snapped ? clamped : null); + } else if (draggingRef.current === "end") { + const snap = calculateSnap(currentSeconds, 0.25); + const clamped = Math.min(duration, Math.max(snap.time, recipe.trimStart + 0.1)); + onChange({ trimEnd: parseFloat(clamped.toFixed(2)) }); + setSnapGuideTime(snap.snapped ? clamped : null); + } else if (draggingRef.current === "clip") { + const deltaSeconds = currentSeconds - dragStartOffsetRef.current; + let newStart = dragStartClipRef.current.start + deltaSeconds; + let newEnd = dragStartClipRef.current.end + deltaSeconds; + + // Handle boundaries + if (newStart < 0) { + newEnd -= newStart; + newStart = 0; + } + if (newEnd > duration) { + newStart -= (newEnd - duration); + newEnd = duration; + } + + // Snap both sides together + const startSnap = calculateSnap(newStart, 0.25); + const endSnap = calculateSnap(newEnd, 0.25); + + let activeSnapTime: number | null = null; + if (startSnap.snapped) { + const offset = startSnap.time - newStart; + newStart = startSnap.time; + newEnd = Math.min(duration, newEnd + offset); + activeSnapTime = startSnap.time; + } else if (endSnap.snapped) { + const offset = endSnap.time - newEnd; + newEnd = endSnap.time; + newStart = Math.max(0, newStart + offset); + activeSnapTime = endSnap.time; + } + + onChange({ + trimStart: parseFloat(newStart.toFixed(2)), + trimEnd: parseFloat(newEnd.toFixed(2)), + }); + setSnapGuideTime(activeSnapTime); + } + }, [xToSeconds, duration, effectiveTrimEnd, recipe.trimStart, onChange, onSeek, calculateSnap]); + + const handleMouseDown = ( + e: React.MouseEvent, + type: "start" | "end" | "clip" | "playhead" + ) => { + e.preventDefault(); + e.stopPropagation(); + draggingRef.current = type; + + const clientX = e.clientX; + const currentSecs = xToSeconds(clientX); + dragStartOffsetRef.current = currentSecs; + dragStartClipRef.current = { start: recipe.trimStart, end: effectiveTrimEnd }; + + if (type === "playhead") { + handleDrag(clientX); + } + }; + + const handleTouchStart = ( + e: React.TouchEvent, + type: "start" | "end" | "clip" | "playhead" + ) => { + const touch = e.touches[0]; + if (!touch) return; + e.stopPropagation(); + draggingRef.current = type; + + const clientX = touch.clientX; + const currentSecs = xToSeconds(clientX); + dragStartOffsetRef.current = currentSecs; + dragStartClipRef.current = { start: recipe.trimStart, end: effectiveTrimEnd }; + + if (type === "playhead") { + handleDrag(clientX); + } + }; + + useEffect(() => { + const onMove = (e: MouseEvent | TouchEvent) => { + if (!draggingRef.current) return; + + let clientX: number; + if ("touches" in e) { + const touch = e.touches[0]; + if (!touch) return; + clientX = touch.clientX; + } else { + clientX = e.clientX; + } + + handleDrag(clientX); + }; + + const onUp = () => { + draggingRef.current = null; + setSnapGuideTime(null); + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.addEventListener("touchmove", onMove, { passive: true }); + document.addEventListener("touchend", onUp); + + return () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.removeEventListener("touchmove", onMove); + document.removeEventListener("touchend", onUp); + }; + }, [handleDrag]); + + // Grid tick markers for the time ruler + const ticks = useMemo(() => { + const result: { time: number; label: string; xPct: number }[] = []; + const tickInterval = snapStep; + for (let t = 0; t <= duration; t += tickInterval) { + const minutes = Math.floor(t / 60); + const seconds = Math.floor(t % 60); + result.push({ + time: t, + label: `${minutes}:${seconds.toString().padStart(2, "0")}`, + xPct: (t / duration) * 100, + }); + } + return result; + }, [duration, snapStep]); + + if (!videoSrc || duration <= 0) return null; + + const playheadPct = (currentTime / duration) * 100; + const clipLeftPct = (recipe.trimStart / duration) * 100; + const clipRightPct = ((duration - effectiveTrimEnd) / duration) * 100; + + return ( +
+ {/* ── Time Ruler ticks ── */} +
handleMouseDown(e, "playhead")} + onTouchStart={(e) => handleTouchStart(e, "playhead")} + role="slider" + aria-label="Timeline time ruler" + aria-valuenow={currentTime} + aria-valuemin={0} + aria-valuemax={duration} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "ArrowLeft") onSeek(Math.max(0, currentTime - 1)); + if (e.key === "ArrowRight") onSeek(Math.min(duration, currentTime + 1)); + }} + > + {ticks.map((tick) => ( +
+ {tick.label} +
+ ))} +
+ + {/* ── Visual Frame Track & Interactivity ── */} +
+ + {/* Continuous backdrop of generated thumbnails */} +
+ {thumbnails.length === 0 && isGenerating && ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+ )} + {thumbnails.length > 0 && ( +
+ {thumbnails.map((thumb) => ( +
+ Timeline Frame +
+ ))} +
+ )} +
+ + {/* Dimmer overlays for regions out of trim bounds */} +
+
+ + {/* Draggable Active Clip Box */} +
handleMouseDown(e, "clip")} + onTouchStart={(e) => handleTouchStart(e, "clip")} + role="button" + tabIndex={0} + aria-label="Active video clip range" + onKeyDown={(e) => { + if (e.key === "ArrowLeft") { + const newStart = Math.max(0, recipe.trimStart - 0.1); + const offset = recipe.trimStart - newStart; + onChange({ trimStart: parseFloat(newStart.toFixed(2)), trimEnd: parseFloat((effectiveTrimEnd - offset).toFixed(2)) }); + } + if (e.key === "ArrowRight") { + const newEnd = Math.min(duration, effectiveTrimEnd + 0.1); + const offset = newEnd - effectiveTrimEnd; + onChange({ trimStart: parseFloat((recipe.trimStart + offset).toFixed(2)), trimEnd: parseFloat(newEnd.toFixed(2)) }); + } + }} + > + {/* Trim Handles */} +
handleMouseDown(e, "start")} + onTouchStart={(e) => handleTouchStart(e, "start")} + role="slider" + aria-label="Drag start trim handle" + aria-valuenow={recipe.trimStart} + aria-valuemin={0} + aria-valuemax={duration} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) }); + if (e.key === "ArrowRight") onChange({ trimStart: Math.min(effectiveTrimEnd - 0.1, recipe.trimStart + 0.1) }); + }} + > + +
+ +
handleMouseDown(e, "end")} + onTouchStart={(e) => handleTouchStart(e, "end")} + role="slider" + aria-label="Drag end trim handle" + aria-valuenow={effectiveTrimEnd} + aria-valuemin={0} + aria-valuemax={duration} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "ArrowLeft") onChange({ trimEnd: Math.max(recipe.trimStart + 0.1, effectiveTrimEnd - 0.1) }); + if (e.key === "ArrowRight") onChange({ trimEnd: Math.min(duration, effectiveTrimEnd + 0.1) }); + }} + > + +
+
+ + {/* Snap Guide Line */} + {snapGuideTime !== null && ( +
+ )} + + {/* Vertical Red Playhead Bar */} +
handleMouseDown(e, "playhead")} + onTouchStart={(e) => handleTouchStart(e, "playhead")} + role="slider" + aria-label="Playhead scrubber" + aria-valuenow={currentTime} + aria-valuemin={0} + aria-valuemax={duration} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "ArrowLeft") onSeek(Math.max(0, currentTime - 1)); + if (e.key === "ArrowRight") onSeek(Math.min(duration, currentTime + 1)); + }} + > +
+
+
+ + {/* ── Metadata / Status Overlay ── */} +
+ + 🎬 Clip range: {recipe.trimStart.toFixed(1)}s – {effectiveTrimEnd.toFixed(1)}s + + + ⏰ Length: {formatDuration(clipLength)} of {formatDuration(duration)} + +
+ + +
+ ); +} diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx index d48bdd69..27b48daa 100644 --- a/src/components/TrimControl.tsx +++ b/src/components/TrimControl.tsx @@ -177,64 +177,7 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) return (
- {duration > 0 && ( -
{ - if (dragging.current) return; - const s = xToSeconds(e.clientX); - onChange({ trimStart: s }); - }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimStart: Math.min((recipe.trimEnd ?? duration) - 0.1, recipe.trimStart + 0.1) }); - }} - > -
-
-
{ dragging.current = "start"; }} - onTouchStart={() => { dragging.current = "start"; }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimStart: Math.min((recipe.trimEnd ?? duration) - 0.1, recipe.trimStart + 0.1) }); - }} - /> -
{ dragging.current = "end"; }} - onTouchStart={() => { dragging.current = "end"; }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimEnd: Math.max(recipe.trimStart + 0.1, (recipe.trimEnd ?? duration) - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimEnd: Math.min(duration, (recipe.trimEnd ?? duration) + 0.1) }); - }} - /> -
- )} +