scrape / components /video-preview.tsx
kjbytes's picture
push code
ef4c36f
Raw
History Blame Contribute Delete
4.86 kB
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Pause, Play, RotateCcw, SkipBack, SkipForward } from "lucide-react";
import type {
AnimationSettings,
Caption,
CaptionLayout,
CaptionStyle,
VideoMeta,
} from "@/types";
import { Button } from "@/components/ui/button";
import { CaptionWord } from "@/components/caption-word";
import { cn } from "@/lib/utils";
interface VideoPreviewProps {
videoUrl: string;
audioUrl?: string;
captions: Caption[];
style: CaptionStyle;
layout: CaptionLayout;
animation: AnimationSettings;
videoMeta: VideoMeta;
}
export function VideoPreview({
videoUrl,
audioUrl,
captions,
style,
layout,
animation,
videoMeta,
}: VideoPreviewProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [currentMs, setCurrentMs] = useState(0);
const [playing, setPlaying] = useState(false);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const onTimeUpdate = () => setCurrentMs(video.currentTime * 1000);
video.addEventListener("timeupdate", onTimeUpdate);
return () => video.removeEventListener("timeupdate", onTimeUpdate);
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video || !audioUrl) return;
video.muted = true;
}, [audioUrl]);
const togglePlay = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
void video.play();
setPlaying(true);
} else {
video.pause();
setPlaying(false);
}
}, []);
const seekBy = (delta: number) => {
const video = videoRef.current;
if (!video) return;
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + delta));
};
const restart = () => {
const video = videoRef.current;
if (!video) return;
video.currentTime = 0;
setCurrentMs(0);
};
// ponytail: strict window — the +200ms grace stacked the outgoing phrase under the new one
const visibleCaptions = captions.filter(
(c) => c.startMs <= currentMs && currentMs < c.endMs,
);
const positionClass =
layout.position === "top"
? "top-[15%]"
: layout.position === "center"
? "top-1/2 -translate-y-1/2"
: "bottom-[15%]";
return (
<div className="space-y-4">
{/* ponytail: container-query context so captions scale with the preview instead
of using raw export px, which looked ~4x too big in a 292px-wide frame */}
<div
className="relative overflow-hidden rounded-xl bg-black shadow-lg"
style={{ containerType: "inline-size" }}
>
<video
ref={videoRef}
src={videoUrl}
className="aspect-[9/16] max-h-[520px] w-full object-contain"
playsInline
muted={!!audioUrl}
onEnded={() => setPlaying(false)}
/>
{audioUrl && (
<audio ref={(el) => {
if (el && videoRef.current) {
videoRef.current.onplay = () => void el.play();
videoRef.current.onpause = () => el.pause();
videoRef.current.onseeked = () => { el.currentTime = videoRef.current!.currentTime; };
}
}} src={audioUrl} />
)}
<div
className={cn(
"pointer-events-none absolute inset-x-0 flex justify-center px-4",
positionClass,
)}
style={{ transform: layout.yOffset ? `translateY(${layout.yOffset}px)` : undefined }}
>
<div
className="flex flex-wrap justify-center gap-2 rounded-[1.7cqw] px-[2.6cqw] py-[1.7cqw]"
style={{ backgroundColor: style.backdrop }}
>
{visibleCaptions.map((caption) => (
<CaptionWord
key={caption.id}
caption={caption}
currentMs={currentMs}
style={style}
animation={animation}
/>
))}
</div>
</div>
</div>
<div className="flex items-center justify-center gap-2">
<Button variant="outline" size="icon" onClick={() => seekBy(-5)}>
<SkipBack className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={restart}>
<RotateCcw className="h-4 w-4" />
</Button>
<Button size="icon" onClick={togglePlay}>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button variant="outline" size="icon" onClick={() => seekBy(5)}>
<SkipForward className="h-4 w-4" />
</Button>
</div>
<p className="text-center text-xs text-zinc-500">
{videoMeta.width}×{videoMeta.height} • {videoMeta.fps} fps • {Math.round(videoMeta.duration)}s
</p>
</div>
);
}