Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function ThemeToggle() {
}, []);

const isDark = theme === "dark";

if (!mounted) {
return (
<button
Expand All @@ -30,10 +30,10 @@ export function ThemeToggle() {

return (
<button
type="button"
suppressHydrationWarning={true}
onClick={toggleTheme}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
type="button"
suppressHydrationWarning={true}
onClick={toggleTheme}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
className="
relative flex items-center justify-center
w-9 h-9 rounded-full
Expand All @@ -46,14 +46,34 @@ export function ThemeToggle() {
transition-all duration-200
"
>
{!mounted ? (
<div className="w-4 h-4" />
{!mounted ? (
<div className="w-4 h-4" />
) : isDark ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="w-4 h-4"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4" aria-hidden="true">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="w-4 h-4"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
Expand Down
256 changes: 160 additions & 96 deletions src/components/TrimControl.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { EditRecipe } from "@/lib/types";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, type PointerEvent } from "react";
import { AlertCircle } from "lucide-react";
import { formatDuration } from "@/lib/utils";
import { useAudioWaveform } from "@/hooks/useAudioWaveform";
Expand All @@ -14,26 +14,113 @@ interface Props {
onChange: (patch: Partial<EditRecipe>) => void;
duration: number;
file: File | null;
seekTo?: (time: number) => void;
videoRef: React.RefObject<HTMLVideoElement | null>; // Add this
}

export default function TrimControl({ recipe, onChange, duration, file }: Props) {


export default function TrimControl({ recipe, onChange, duration, file, seekTo, videoRef}: Props) {
const [invalidStart, setStart] = useState(false);
const [invalidEnd, setEnd] = useState(false);
const [startErrorMsg, setStartErrorMsg] = useState("");
const [endErrorMsg, setEndErrorMsg] = useState("");
const [startInput, setStartInput] = useState(
recipe.trimStart.toString()
);
const [startInput, setStartInput] = useState(recipe.trimStart.toString());
const [draggingThumb, setDraggingThumb] = useState<"start" | "end" | null>(null);

const { waveform, isLoading: waveformLoading } = useAudioWaveform(file);
const hasAudio = waveform.length > 0;

const [frames, setFrames] = useState<string[]>([]);

useEffect(() => {
const video = videoRef.current;
if (!video || frames.length > 0) return;

const captureFrames = async () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const frameList: string[] = [];
const steps = 10;

// Temporarily ensure video is ready
if (video.readyState < 2) await new Promise(r => video.addEventListener('loadedmetadata', r, {once: true}));

for (let i = 0; i < steps; i++) {
video.currentTime = (i / (steps - 1)) * video.duration;
await new Promise(r => setTimeout(r, 150));
if (ctx) {
canvas.width = 100;
canvas.height = 64;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
frameList.push(canvas.toDataURL("image/jpeg", 0.6));
}
}
setFrames(frameList);
};

captureFrames();
}, [videoRef, frames.length]);

useEffect(() => {
setStartInput(recipe.trimStart.toString());
}, [recipe.trimStart]);

const clipLength =
(recipe.trimEnd ?? duration) - recipe.trimStart;
const clipLength = (recipe.trimEnd ?? duration) - recipe.trimStart;
const trimEndValue = recipe.trimEnd ?? duration;
const startPercent = duration > 0 ? Math.min(100, Math.max(0, (recipe.trimStart / duration) * 100)) : 0;
const endPercent = duration > 0 ? Math.min(100, Math.max(0, (trimEndValue / duration) * 100)) : 100;

const updateTrimFromPointer = (
event: PointerEvent<HTMLDivElement>,
thumb: "start" | "end",
) => {
if (duration <= 0) return;

const rect = event.currentTarget.getBoundingClientRect();
const percent = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
const newValue = percent * duration;

if (thumb === "start") {
handleStart(newValue.toString());
} else {
handleEnd(newValue.toString());
}

// NEW: Force the preview to sync immediately to the new time
if (seekTo) {
seekTo(newValue);
}
};

const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const thumb = (event.target as HTMLElement).closest("[data-thumb]")?.getAttribute("data-thumb");

if (thumb !== "start" && thumb !== "end") {
return;
}

event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDraggingThumb(thumb);
updateTrimFromPointer(event, thumb);
};

const handleTrackPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!draggingThumb) {
return;
}

updateTrimFromPointer(event, draggingThumb);
};

const handleTrackPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}

setDraggingThumb(null);
};

const trackRef = useRef<HTMLDivElement>(null);
const dragging = useRef<"start" | "end" | null>(null);
Expand All @@ -57,39 +144,38 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
}
}, [xToSeconds, duration, recipe.trimStart, recipe.trimEnd, onChange]);

useEffect(() => {
const onMove = (e: MouseEvent | TouchEvent) => {
let clientX: number;

if ("touches" in e) {
const touch = e.touches[0];

if (!touch) return;

clientX = touch.clientX;
} else {
clientX = e.clientX;
}

applyDrag(clientX);
};

const onUp = () => {
dragging.current = null;
};

document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.addEventListener("touchmove", onMove);
document.addEventListener("touchend", onUp);
useEffect(() => {
const onMove = (e: MouseEvent | TouchEvent) => {
let clientX: number;

if ("touches" in e) {
const touch = e.touches[0];
if (!touch) return;
clientX = touch.clientX;
} else {
clientX = e.clientX;
}

applyDrag(clientX);
};

const onUp = () => {
dragging.current = null;
};

document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.addEventListener("touchmove", onMove);
document.addEventListener("touchend", onUp);

return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.removeEventListener("touchmove", onMove);
document.removeEventListener("touchend", onUp);
};
}, [applyDrag]);

return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.removeEventListener("touchmove", onMove);
document.removeEventListener("touchend", onUp);
};
}, [applyDrag]);
const handleStart = (val: string) => {
setStartInput(val);

Expand Down Expand Up @@ -177,62 +263,42 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)

return (
<div id="trim-control" className="space-y-3">
{duration > 0 && (
{/* Waveform — shown while loading or when file is present */}
{/* Static Frame Strip across the Trim Bar */}
{file && (
<div
role="toolbar"
aria-label="Trim timeline"
ref={trackRef}
className="relative h-6 flex items-center cursor-pointer select-none"
onClick={(e) => {
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) });
}}
className="relative w-full h-16 rounded-md overflow-hidden bg-neutral-900 touch-none border border-[var(--border)]"
onPointerDown={handleTrackPointerDown}
onPointerMove={handleTrackPointerMove}
onPointerUp={handleTrackPointerUp}
onPointerCancel={handleTrackPointerUp}
>
<div className="absolute inset-x-0 h-1.5 rounded-full bg-[var(--border)]" />
<div
className="absolute h-1.5 rounded-full bg-film-400 opacity-60"
style={{
left: `${(recipe.trimStart / duration) * 100}%`,
right: `${((duration - (recipe.trimEnd ?? duration)) / duration) * 100}%`,
}}
/>
<div
role="slider"
aria-label="Trim start"
aria-valuenow={recipe.trimStart}
aria-valuemin={0}
aria-valuemax={duration}
tabIndex={0}
className="absolute w-4 h-4 rounded-full bg-white border-2 border-film-400 shadow cursor-grab active:cursor-grabbing -translate-x-1/2 focus:outline-none focus:ring-2 focus:ring-film-400"
style={{ left: `${(recipe.trimStart / duration) * 100}%` }}
onMouseDown={() => { 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) });
}}
/>
<div
role="slider"
aria-label="Trim end"
aria-valuenow={recipe.trimEnd ?? duration}
aria-valuemin={0}
aria-valuemax={duration}
tabIndex={0}
className="absolute w-4 h-4 rounded-full bg-white border-2 border-film-400 shadow cursor-grab active:cursor-grabbing -translate-x-1/2 focus:outline-none focus:ring-2 focus:ring-film-400"
style={{ left: `${((recipe.trimEnd ?? duration) / duration) * 100}%` }}
onMouseDown={() => { 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) });
}}
/>
{/* Static Frame Strip */}
<div className="absolute inset-0 flex h-full">
{frames.length > 0 ? (
frames.map((src, i) => (
<div
key={i}
className="h-full flex-1 border-r border-black/20 bg-cover bg-center"
style={{ backgroundImage: `url(${src})` }}
/>
))
) : (
<div className="w-full h-full animate-pulse bg-neutral-800" />
)}
</div>

{/* Selection Overlay (Handles) */}
{duration > 0 && (
<div className="pointer-events-none absolute inset-0">
<div
className="absolute top-0 h-full bg-film-400/30 border-y border-film-400"
style={{ left: `${startPercent}%`, width: `${Math.max(0, endPercent - startPercent)}%` }}
/>
<button data-thumb="start" className="pointer-events-auto absolute top-0 h-full w-3 -translate-x-1/2 bg-white shadow-lg cursor-ew-resize" style={{ left: `${startPercent}%` }} />
<button data-thumb="end" className="pointer-events-auto absolute top-0 h-full w-3 -translate-x-1/2 bg-white shadow-lg cursor-ew-resize" style={{ left: `${endPercent}%` }} />
</div>
)}
</div>
)}
<div className="flex gap-3">
Expand Down Expand Up @@ -325,6 +391,4 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
)}
</div>
);
}


}
16 changes: 3 additions & 13 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,18 +444,6 @@ return () => {
onSelectText={setSelectedTextId}
onUpdateText={handleUpdateTextOverlay}
/>

<div className="mt-3">
<ThumbnailStrip
videoSrc={videoSrc}
duration={duration}
currentTime={currentTime}
trimStart={recipe.trimStart ?? 0}
trimEnd={recipe.trimEnd ?? duration}
onSeek={seekTo}
intervalSeconds={intervalSeconds}
/>
</div>
</div>
)}
</div>
Expand Down Expand Up @@ -483,7 +471,9 @@ return () => {
recipe={recipe}
onChange={updateRecipe}
duration={duration}
file={file}
file={file}
seekTo={seekTo}
videoRef={videoRef}
/>
</AccordionSection>

Expand Down
Loading
Loading