OpenCut/legacy/preview-panel.tsx

1133 lines
36 KiB
TypeScript

"use client";
import { useTimelineStore } from "@/stores/timeline-store";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
import { useMediaStore } from "@/stores/media-store";
import { MediaFile } from "@/types/media";
import { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store";
import { PLATFORM_LAYOUTS } from "@/constants/editor-constants";
import type { PlatformLayout } from "@/types/editor";
import { Button } from "@/components/ui/button";
import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react";
import { useState, useRef, useEffect, useCallback } from "react";
import { renderTimelineFrame } from "@/lib/timeline-renderer";
import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time-utils";
import { EditableTimecode } from "@/components/ui/editable-timecode";
import { useFrameCache } from "@/hooks/use-frame-cache";
import { useSceneStore } from "@/stores/scene-store";
import { DEFAULT_CANVAS_SIZE, DEFAULT_FPS } from "@/constants/editor-constants";
import { useProjectStore } from "@/stores/project-store";
import { TextElementDragState } from "@/types/editor";
import { LayoutGuideOverlay } from "./layout-guide-overlay";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { SocialsIcon } from "@/components/icons";
interface ActiveElement {
element: TimelineElement;
track: TimelineTrack;
mediaItem: MediaFile | null;
}
export function PreviewPanel() {
const { tracks, getTotalDuration, updateTextElement } = useTimelineStore();
const { mediaFiles } = useMediaStore();
const { currentTime, isPlaying, volume, muted, toggle, setCurrentTime } =
usePlaybackStore();
const { activeProject } = useProjectStore();
const { currentScene } = useSceneStore();
const previewRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } =
useFrameCache();
const lastFrameTimeRef = useRef(0);
const renderSeqRef = useRef(0);
const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>(
null,
);
const audioContextRef = useRef<AudioContext | null>(null);
const audioGainRef = useRef<GainNode | null>(null);
const audioBuffersRef = useRef<Map<string, AudioBuffer>>(new Map());
const playingSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const [previewDimensions, setPreviewDimensions] = useState({
width: 0,
height: 0,
});
const [isExpanded, setIsExpanded] = useState(false);
const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE;
const [dragState, setDragState] = useState<TextElementDragState>({
isDragging: false,
elementId: null,
trackId: null,
startX: 0,
startY: 0,
initialElementX: 0,
initialElementY: 0,
currentX: 0,
currentY: 0,
elementWidth: 0,
elementHeight: 0,
});
useEffect(() => {
const updatePreviewSize = () => {
if (!containerRef.current) return;
let availableWidth, availableHeight;
if (isExpanded) {
const controlsHeight = 80;
const marginSpace = 24;
availableWidth = window.innerWidth - marginSpace;
availableHeight = window.innerHeight - controlsHeight - marginSpace;
} else {
const container = containerRef.current.getBoundingClientRect();
const computedStyle = getComputedStyle(containerRef.current);
const paddingTop = parseFloat(computedStyle.paddingTop);
const paddingBottom = parseFloat(computedStyle.paddingBottom);
const paddingLeft = parseFloat(computedStyle.paddingLeft);
const paddingRight = parseFloat(computedStyle.paddingRight);
const gap = parseFloat(computedStyle.gap) || 16;
const toolbar = containerRef.current.querySelector("[data-toolbar]");
const toolbarHeight = toolbar
? toolbar.getBoundingClientRect().height
: 0;
availableWidth = container.width - paddingLeft - paddingRight;
availableHeight =
container.height -
paddingTop -
paddingBottom -
toolbarHeight -
(toolbarHeight > 0 ? gap : 0);
}
const targetRatio = canvasSize.width / canvasSize.height;
const containerRatio = availableWidth / availableHeight;
let width, height;
if (containerRatio > targetRatio) {
height = availableHeight * (isExpanded ? 0.95 : 1);
width = height * targetRatio;
} else {
width = availableWidth * (isExpanded ? 0.95 : 1);
height = width / targetRatio;
}
setPreviewDimensions({ width, height });
};
updatePreviewSize();
const resizeObserver = new ResizeObserver(updatePreviewSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
if (isExpanded) {
window.addEventListener("resize", updatePreviewSize);
}
return () => {
resizeObserver.disconnect();
if (isExpanded) {
window.removeEventListener("resize", updatePreviewSize);
}
};
}, [canvasSize.width, canvasSize.height, isExpanded]);
useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === "Escape" && isExpanded) {
setIsExpanded(false);
}
};
if (isExpanded) {
document.addEventListener("keydown", handleEscapeKey);
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.removeEventListener("keydown", handleEscapeKey);
document.body.style.overflow = "";
};
}, [isExpanded]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragState.isDragging) return;
const deltaX = e.clientX - dragState.startX;
const deltaY = e.clientY - dragState.startY;
const scaleRatio = previewDimensions.width / canvasSize.width;
const newX = dragState.initialElementX + deltaX / scaleRatio;
const newY = dragState.initialElementY + deltaY / scaleRatio;
const halfWidth = dragState.elementWidth / scaleRatio / 2;
const halfHeight = dragState.elementHeight / scaleRatio / 2;
const constrainedX = Math.max(
-canvasSize.width / 2 + halfWidth,
Math.min(canvasSize.width / 2 - halfWidth, newX),
);
const constrainedY = Math.max(
-canvasSize.height / 2 + halfHeight,
Math.min(canvasSize.height / 2 - halfHeight, newY),
);
setDragState((prev) => ({
...prev,
currentX: constrainedX,
currentY: constrainedY,
}));
};
const handleMouseUp = () => {
if (dragState.isDragging && dragState.trackId && dragState.elementId) {
updateTextElement(dragState.trackId, dragState.elementId, {
x: dragState.currentX,
y: dragState.currentY,
});
}
setDragState((prev) => ({ ...prev, isDragging: false }));
};
if (dragState.isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [dragState, previewDimensions, canvasSize, updateTextElement]);
// Clear the frame cache when background settings change since they affect rendering
useEffect(() => {
invalidateCache();
}, [
mediaFiles,
activeProject?.backgroundColor,
activeProject?.backgroundType,
invalidateCache,
]);
const handleTextMouseDown = (
e: React.MouseEvent<HTMLDivElement>,
element: any,
trackId: string,
) => {
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setDragState({
isDragging: true,
elementId: element.id,
trackId,
startX: e.clientX,
startY: e.clientY,
initialElementX: element.x,
initialElementY: element.y,
currentX: element.x,
currentY: element.y,
elementWidth: rect.width,
elementHeight: rect.height,
});
};
const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
const shouldRenderPreview = hasAnyElements || activeProject?.backgroundType;
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
// Iterate tracks from bottom to top so topmost track renders last (on top)
[...tracks].reverse().forEach((track) => {
track.elements.forEach((element) => {
if (element.hidden) return;
const elementStart = element.startTime;
const elementEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime >= elementStart && currentTime < elementEnd) {
let mediaItem = null;
if (element.type === "media") {
mediaItem =
element.mediaId === "test"
? null
: mediaFiles.find((item) => item.id === element.mediaId) ||
null;
}
activeElements.push({ element, track, mediaItem });
}
});
});
return activeElements;
};
const activeElements = getActiveElements();
// Ensure first frame after mount/seek renders immediately
useEffect(() => {
const onSeek = () => {
lastFrameTimeRef.current = -Infinity;
renderSeqRef.current++;
};
window.addEventListener("playback-seek", onSeek as EventListener);
lastFrameTimeRef.current = -Infinity;
return () => {
window.removeEventListener("playback-seek", onSeek as EventListener);
};
}, []);
// Web Audio: schedule only on play/pause/seek/volume/mute changes
useEffect(() => {
const stopAll = () => {
for (const src of playingSourcesRef.current) {
try {
src.stop();
} catch {}
}
playingSourcesRef.current.clear();
};
type WebAudioWindow = Window & {
AudioContext?: typeof AudioContext;
webkitAudioContext?: typeof AudioContext;
};
const ensureAudioGraph = async () => {
if (!audioContextRef.current) {
const win = window as WebAudioWindow;
const Ctx = win.AudioContext ?? win.webkitAudioContext;
if (!Ctx) return;
audioContextRef.current = new Ctx();
}
if (!audioGainRef.current) {
audioGainRef.current = audioContextRef.current!.createGain();
audioGainRef.current.connect(audioContextRef.current!.destination);
}
if (audioContextRef.current!.state === "suspended") {
try {
await audioContextRef.current!.resume();
} catch {}
}
const gainValue = muted ? 0 : Math.max(0, Math.min(1, volume));
audioGainRef.current!.gain.setValueAtTime(
gainValue,
audioContextRef.current!.currentTime,
);
};
const scheduleNow = async () => {
await ensureAudioGraph();
const audioCtx = audioContextRef.current!;
const gain = audioGainRef.current!;
const tracksSnapshot = useTimelineStore.getState().tracks;
const mediaList = mediaFiles;
const idToMedia = new Map(mediaList.map((m) => [m.id, m] as const));
const playbackNow = usePlaybackStore.getState().currentTime;
const audible: Array<{
id: string;
elementStart: number;
trimStart: number;
trimEnd: number;
duration: number;
muted: boolean;
trackMuted: boolean;
}> = [];
const uniqueIds = new Set<string>();
for (const track of tracksSnapshot) {
for (const element of track.elements) {
if (element.type !== "media") continue;
const media = idToMedia.get(element.mediaId);
if (!media || media.type !== "audio") continue;
const visibleDuration =
element.duration - element.trimStart - element.trimEnd;
if (visibleDuration <= 0) continue;
const localTime = playbackNow - element.startTime + element.trimStart;
if (localTime < 0 || localTime >= visibleDuration) continue;
audible.push({
id: media.id,
elementStart: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
duration: element.duration,
muted: !!element.muted,
trackMuted: !!track.muted,
});
uniqueIds.add(media.id);
}
}
if (audible.length === 0) return;
// Decode buffers as needed
const decodePromises: Array<Promise<void>> = [];
for (const id of uniqueIds) {
if (!audioBuffersRef.current.has(id)) {
const mediaItem = idToMedia.get(id);
if (!mediaItem) continue;
const p = (async () => {
const arr = await mediaItem.file.arrayBuffer();
const buf = await audioCtx.decodeAudioData(arr.slice(0));
audioBuffersRef.current.set(id, buf);
})();
decodePromises.push(p);
}
}
await Promise.all(decodePromises);
const startAt = audioCtx.currentTime + 0.02;
for (const entry of audible) {
if (entry.muted || entry.trackMuted) continue;
const buffer = audioBuffersRef.current.get(entry.id);
if (!buffer) continue;
const visibleDuration =
entry.duration - entry.trimStart - entry.trimEnd;
const localTime = Math.max(
0,
playbackNow - entry.elementStart + entry.trimStart,
);
const playDuration = Math.max(0, visibleDuration - localTime);
if (playDuration <= 0) continue;
const src = audioCtx.createBufferSource();
src.buffer = buffer;
src.connect(gain);
try {
src.start(startAt, localTime, playDuration);
playingSourcesRef.current.add(src);
} catch {}
}
};
const onSeek = () => {
if (!isPlaying) return;
for (const src of playingSourcesRef.current) {
try {
src.stop();
} catch {}
}
playingSourcesRef.current.clear();
void scheduleNow();
};
// Apply volume/mute changes immediately
void ensureAudioGraph();
// Start/stop on play state changes
for (const src of playingSourcesRef.current) {
try {
src.stop();
} catch {}
}
playingSourcesRef.current.clear();
if (isPlaying) {
void scheduleNow();
}
window.addEventListener("playback-seek", onSeek as EventListener);
return () => {
window.removeEventListener("playback-seek", onSeek as EventListener);
for (const src of playingSourcesRef.current) {
try {
src.stop();
} catch {}
}
playingSourcesRef.current.clear();
};
}, [isPlaying, volume, muted, mediaFiles]);
// Canvas: draw current frame with caching
useEffect(() => {
const draw = async () => {
const canvas = canvasRef.current;
if (!canvas) return;
const mainCtx = canvas.getContext("2d");
if (!mainCtx) return;
// Set canvas internal resolution to avoid blurry scaling
const displayWidth = Math.max(1, Math.floor(previewDimensions.width));
const displayHeight = Math.max(1, Math.floor(previewDimensions.height));
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
canvas.width = displayWidth;
canvas.height = displayHeight;
}
// Throttle rendering to project FPS during playback only
const fps = activeProject?.fps || DEFAULT_FPS;
const minDelta = 1 / fps;
if (isPlaying) {
if (currentTime - lastFrameTimeRef.current < minDelta) {
return;
}
lastFrameTimeRef.current = currentTime;
}
const cachedFrame = getCachedFrame(
currentTime,
tracks,
mediaFiles,
activeProject,
currentScene?.id,
);
if (cachedFrame) {
mainCtx.putImageData(cachedFrame, 0, 0);
// Pre-render nearby frames in background
if (!isPlaying) {
// Only during scrubbing to avoid interfering with playback
preRenderNearbyFrames(
currentTime,
tracks,
mediaFiles,
activeProject,
async (time: number) => {
const tempCanvas = document.createElement("canvas");
tempCanvas.width = displayWidth;
tempCanvas.height = displayHeight;
const tempCtx = tempCanvas.getContext("2d");
if (!tempCtx)
throw new Error("Failed to create temp canvas context");
await renderTimelineFrame({
ctx: tempCtx,
time,
canvasWidth: displayWidth,
canvasHeight: displayHeight,
tracks,
mediaFiles,
backgroundType: activeProject?.backgroundType,
blurIntensity: activeProject?.blurIntensity,
backgroundColor:
activeProject?.backgroundType === "blur"
? undefined
: activeProject?.backgroundColor || "#000000",
projectCanvasSize: canvasSize,
});
return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
},
currentScene?.id,
3,
);
} else {
// Small lookahead while playing
preRenderNearbyFrames(
currentTime,
tracks,
mediaFiles,
activeProject,
async (time: number) => {
const tempCanvas = document.createElement("canvas");
tempCanvas.width = displayWidth;
tempCanvas.height = displayHeight;
const tempCtx = tempCanvas.getContext("2d");
if (!tempCtx)
throw new Error("Failed to create temp canvas context");
await renderTimelineFrame({
ctx: tempCtx,
time,
canvasWidth: displayWidth,
canvasHeight: displayHeight,
tracks,
mediaFiles,
backgroundType: activeProject?.backgroundType,
blurIntensity: activeProject?.blurIntensity,
backgroundColor:
activeProject?.backgroundType === "blur"
? undefined
: activeProject?.backgroundColor || "#000000",
projectCanvasSize: canvasSize,
});
return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
},
currentScene?.id,
1,
);
}
return;
}
// Cache miss - render from scratch
if (!offscreenCanvasRef.current) {
const hasOffscreen =
typeof (globalThis as unknown as { OffscreenCanvas?: unknown })
.OffscreenCanvas !== "undefined";
if (hasOffscreen) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
offscreenCanvasRef.current = new (globalThis as any).OffscreenCanvas(
displayWidth,
displayHeight,
) as OffscreenCanvas;
} else {
const c = document.createElement("canvas");
c.width = displayWidth;
c.height = displayHeight;
offscreenCanvasRef.current = c;
}
}
// Ensure size matches
if (
offscreenCanvasRef.current &&
(offscreenCanvasRef.current as HTMLCanvasElement).getContext
) {
const c = offscreenCanvasRef.current as HTMLCanvasElement;
if (c.width !== displayWidth || c.height !== displayHeight) {
c.width = displayWidth;
c.height = displayHeight;
}
} else {
const c = offscreenCanvasRef.current as OffscreenCanvas;
// @ts-ignore width/height exist on OffscreenCanvas in modern browsers
if (
(c as unknown as { width: number }).width !== displayWidth ||
(c as unknown as { height: number }).height !== displayHeight
) {
// @ts-ignore
(c as unknown as { width: number }).width = displayWidth;
// @ts-ignore
(c as unknown as { height: number }).height = displayHeight;
}
}
const offscreenCanvas = offscreenCanvasRef.current as
| HTMLCanvasElement
| OffscreenCanvas;
const offCtx = (offscreenCanvas as HTMLCanvasElement).getContext
? (offscreenCanvas as HTMLCanvasElement).getContext("2d")
: (offscreenCanvas as OffscreenCanvas).getContext("2d");
if (!offCtx) return;
await renderTimelineFrame({
ctx: offCtx as CanvasRenderingContext2D,
time: currentTime,
canvasWidth: displayWidth,
canvasHeight: displayHeight,
tracks,
mediaFiles,
backgroundType: activeProject?.backgroundType,
blurIntensity: activeProject?.blurIntensity,
backgroundColor:
activeProject?.backgroundType === "blur"
? undefined
: activeProject?.backgroundColor || "#000000",
projectCanvasSize: canvasSize,
});
const imageData = (offCtx as CanvasRenderingContext2D).getImageData(
0,
0,
displayWidth,
displayHeight,
);
cacheFrame(
currentTime,
imageData,
tracks,
mediaFiles,
activeProject,
currentScene?.id,
);
// Blit offscreen to visible canvas
mainCtx.clearRect(0, 0, displayWidth, displayHeight);
if ((offscreenCanvas as HTMLCanvasElement).getContext) {
mainCtx.drawImage(offscreenCanvas as HTMLCanvasElement, 0, 0);
} else {
mainCtx.drawImage(
offscreenCanvas as unknown as CanvasImageSource,
0,
0,
);
}
};
void draw();
}, [
activeElements,
currentTime,
previewDimensions.width,
previewDimensions.height,
canvasSize.width,
canvasSize.height,
activeProject?.backgroundType,
activeProject?.backgroundColor,
getCachedFrame,
cacheFrame,
preRenderNearbyFrames,
isPlaying,
]);
// Get media elements for blur background (video/image only)
const getBlurBackgroundElements = (): ActiveElement[] => {
return activeElements.filter(
({ element, mediaItem }) =>
element.type === "media" &&
mediaItem &&
(mediaItem.type === "video" || mediaItem.type === "image") &&
element.mediaId !== "test", // Exclude test elements
);
};
const blurBackgroundElements = getBlurBackgroundElements();
// Render blur background layer (handled by canvas now)
const renderBlurBackground = () => null;
// Render an element (canvas handles visuals now). Audio playback to be implemented via Web Audio.
const renderElement = (_elementData: ActiveElement) => null;
return (
<>
<div className="bg-panel relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm">
<div
ref={containerRef}
className="flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center"
>
<div className="flex-1" />
{shouldRenderPreview ? (
<div
ref={previewRef}
className="relative overflow-hidden border"
style={{
width: previewDimensions.width,
height: previewDimensions.height,
background:
activeProject?.backgroundType === "blur"
? "transparent"
: activeProject?.backgroundColor || "#000000",
}}
>
{renderBlurBackground()}
<canvas
ref={canvasRef}
style={{
position: "absolute",
left: 0,
top: 0,
width: previewDimensions.width,
height: previewDimensions.height,
}}
aria-label="Video preview canvas"
/>
{activeElements.length === 0 ? (
<></>
) : (
activeElements.map((elementData) => renderElement(elementData))
)}
<LayoutGuideOverlay />
</div>
) : null}
<div className="flex-1" />
<PreviewToolbar
hasAnyElements={hasAnyElements}
onToggleExpanded={toggleExpanded}
isExpanded={isExpanded}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
toggle={toggle}
getTotalDuration={getTotalDuration}
/>
</div>
</div>
{isExpanded && (
<FullscreenPreview
previewDimensions={previewDimensions}
activeProject={activeProject}
renderBlurBackground={renderBlurBackground}
activeElements={activeElements}
renderElement={renderElement}
blurBackgroundElements={blurBackgroundElements}
hasAnyElements={hasAnyElements}
toggleExpanded={toggleExpanded}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
toggle={toggle}
getTotalDuration={getTotalDuration}
/>
)}
</>
);
}
function FullscreenToolbar({
hasAnyElements,
onToggleExpanded,
currentTime,
setCurrentTime,
toggle,
getTotalDuration,
}: {
hasAnyElements: boolean;
onToggleExpanded: () => void;
currentTime: number;
setCurrentTime: (time: number) => void;
toggle: () => void;
getTotalDuration: () => number;
}) {
const { isPlaying, seek } = usePlaybackStore();
const { activeProject } = useProjectStore();
const [isDragging, setIsDragging] = useState(false);
const totalDuration = getTotalDuration();
const progress = totalDuration > 0 ? (currentTime / totalDuration) * 100 : 0;
const handleTimelineClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!hasAnyElements) return;
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, clickX / rect.width));
const newTime = percentage * totalDuration;
setCurrentTime(Math.max(0, Math.min(newTime, totalDuration)));
};
const handleTimelineDrag = (e: React.MouseEvent<HTMLDivElement>) => {
if (!hasAnyElements) return;
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setIsDragging(true);
const handleMouseMove = (moveEvent: MouseEvent) => {
moveEvent.preventDefault();
const dragX = moveEvent.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, dragX / rect.width));
const newTime = percentage * totalDuration;
setCurrentTime(Math.max(0, Math.min(newTime, totalDuration)));
};
const handleMouseUp = () => {
setIsDragging(false);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
};
document.body.style.userSelect = "none";
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
handleMouseMove(e.nativeEvent);
};
const skipBackward = () => {
const newTime = Math.max(0, currentTime - 1);
setCurrentTime(newTime);
};
const skipForward = () => {
const newTime = Math.min(totalDuration, currentTime + 1);
setCurrentTime(newTime);
};
return (
<div
data-toolbar
className="text-foreground relative flex w-full items-center gap-2 p-1 pt-2"
>
<div className="text-foreground/90 flex items-center gap-1 text-[0.70rem] tabular-nums">
<EditableTimecode
time={currentTime}
duration={totalDuration}
format="HH:MM:SS:FF"
fps={activeProject?.fps || DEFAULT_FPS}
onTimeChange={seek}
disabled={!hasAnyElements}
className="text-foreground/90 hover:bg-white/10"
/>
<span className="opacity-50">/</span>
<span>
{formatTimeCode(
totalDuration,
"HH:MM:SS:FF",
activeProject?.fps || DEFAULT_FPS,
)}
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="text"
size="icon"
onClick={skipBackward}
disabled={!hasAnyElements}
className="text-foreground h-auto p-0"
title="Skip backward 1s"
>
<SkipBack className="h-3 w-3" />
</Button>
<Button
variant="text"
size="icon"
onClick={toggle}
disabled={!hasAnyElements}
className="text-foreground hover:text-foreground/80 h-auto p-0"
>
{isPlaying ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
<Button
variant="text"
size="icon"
onClick={skipForward}
disabled={!hasAnyElements}
className="text-foreground hover:text-foreground/80 h-auto p-0"
title="Skip forward 1s"
>
<SkipForward className="h-3 w-3" />
</Button>
</div>
<div className="flex flex-1 items-center gap-2">
<div
className={cn(
"bg-foreground/20 relative h-1 flex-1 cursor-pointer rounded-full",
!hasAnyElements && "cursor-not-allowed opacity-50",
)}
onClick={hasAnyElements ? handleTimelineClick : undefined}
onMouseDown={hasAnyElements ? handleTimelineDrag : undefined}
style={{ userSelect: "none" }}
>
<div
className={cn(
"bg-foreground absolute left-0 top-0 h-full rounded-full",
!isDragging && "duration-100",
)}
style={{ width: `${progress}%` }}
/>
<div
className="shadow-xs bg-foreground absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border border-black/20"
style={{ left: `${progress}%` }}
/>
</div>
</div>
<Button
variant="text"
size="icon"
className="size-4! text-foreground/80 hover:text-foreground"
onClick={onToggleExpanded}
title="Exit fullscreen (Esc)"
>
<Expand className="size-4!" />
</Button>
</div>
);
}
function FullscreenPreview({
previewDimensions,
activeProject,
renderBlurBackground,
activeElements,
renderElement,
blurBackgroundElements,
hasAnyElements,
toggleExpanded,
currentTime,
setCurrentTime,
toggle,
getTotalDuration,
}: {
previewDimensions: { width: number; height: number };
activeProject: any;
renderBlurBackground: () => React.ReactNode;
activeElements: ActiveElement[];
renderElement: (elementData: ActiveElement, index: number) => React.ReactNode;
blurBackgroundElements: ActiveElement[];
hasAnyElements: boolean;
toggleExpanded: () => void;
currentTime: number;
setCurrentTime: (time: number) => void;
toggle: () => void;
getTotalDuration: () => number;
}) {
return (
<div className="z-9999 fixed inset-0 flex flex-col">
<div className="bg-background flex flex-1 items-center justify-center">
<div
className="border-border relative m-3 overflow-hidden border"
style={{
width: previewDimensions.width,
height: previewDimensions.height,
background:
activeProject?.backgroundType === "blur"
? "#1a1a1a"
: activeProject?.backgroundColor || "#1a1a1a",
}}
>
{renderBlurBackground()}
{activeElements.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-white/60">
No elements at current time
</div>
) : (
activeElements.map((elementData, index) =>
renderElement(elementData, index),
)
)}
<LayoutGuideOverlay />
</div>
</div>
<div className="bg-background p-4">
<FullscreenToolbar
hasAnyElements={hasAnyElements}
onToggleExpanded={toggleExpanded}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
toggle={toggle}
getTotalDuration={getTotalDuration}
/>
</div>
</div>
);
}
function PreviewToolbar({
hasAnyElements,
onToggleExpanded,
isExpanded,
currentTime,
setCurrentTime,
toggle,
getTotalDuration,
}: {
hasAnyElements: boolean;
onToggleExpanded: () => void;
isExpanded: boolean;
currentTime: number;
setCurrentTime: (time: number) => void;
toggle: () => void;
getTotalDuration: () => number;
}) {
const { isPlaying } = usePlaybackStore();
const { layoutGuide, toggleLayoutGuide } = useEditorStore();
if (isExpanded) {
return (
<FullscreenToolbar
{...{
hasAnyElements,
onToggleExpanded,
currentTime,
setCurrentTime,
toggle,
getTotalDuration,
}}
/>
);
}
return (
<div
data-toolbar
className="flex h-auto w-full justify-end gap-2 pb-5 pr-5 pt-4"
>
<div className="flex items-center gap-2">
<Button
variant="text"
size="icon"
onClick={toggle}
disabled={!hasAnyElements}
className="h-auto p-0"
>
{isPlaying ? (
<Pause className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
<Popover>
<PopoverTrigger asChild>
<Button
variant="text"
size="icon"
className="mr-1 h-auto p-0"
title="Toggle layout guide"
>
<SocialsIcon className="!size-6" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">Layout guide</h4>
<p className="text-muted-foreground text-sm">
Show platform-specific layout guides to help align your
content with interface elements like profile pictures,
usernames, and interaction buttons.
</p>
</div>
<div className="grid gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="none"
checked={layoutGuide.platform === null}
onCheckedChange={() =>
toggleLayoutGuide(layoutGuide.platform || "tiktok")
}
/>
<Label htmlFor="none">None</Label>
</div>
{Object.entries(PLATFORM_LAYOUTS).map(([platform, label]) => (
<div key={platform} className="flex items-center space-x-2">
<Checkbox
id={platform}
checked={layoutGuide.platform === platform}
onCheckedChange={() =>
toggleLayoutGuide(platform as PlatformLayout)
}
/>
<Label htmlFor={platform}>{label}</Label>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
<Button
variant="text"
size="icon"
className="size-4!"
onClick={onToggleExpanded}
title="Enter fullscreen"
>
<Expand className="size-4!" />
</Button>
</div>
</div>
);
}