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