/**
* CapCut-style asset preview overlay rendered inside PreviewPane.
*
* Shown when the user clicks an asset card that has NOT yet been added to the
* timeline. Displays the media (image / video / audio) as a compact floating
* card over the canvas — the canvas stays visible behind a barely-tinted
* click-catcher — without modifying the composition (no undo entry, no file
* mutation).
*
* Dismiss: X button, Escape key, click outside the card, or any playhead
* activity (starting playback / seeking) — the canvas refocuses.
* Switching to another not-added asset replaces the current preview.
*/
import { useEffect, useCallback } from "react";
import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { usePlayerStore } from "../../player/store/playerStore";
import { shouldDismissAssetPreview } from "../../utils/assetPreviewDismiss";
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
function basename(path: string): string {
return path.split("/").pop() ?? path;
}
type AssetKind = "image" | "video" | "audio";
function resolveAssetKind(path: string): AssetKind {
if (VIDEO_EXT.test(path)) return "video";
if (IMAGE_EXT.test(path)) return "image";
return "audio";
}
/** The media element for a previewed asset, chosen by kind. */
function AssetPreviewMedia({
kind,
serveUrl,
name,
}: {
kind: AssetKind;
serveUrl: string;
name: string;
}) {
if (kind === "image") {
return (
);
}
if (kind === "video") {
return (
);
}
return (
);
}
export function AssetPreviewOverlay() {
const previewAsset = useAssetPreviewStore((s) => s.previewAsset);
const previewProjectId = useAssetPreviewStore((s) => s.previewProjectId);
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") clearPreviewAsset();
},
[clearPreviewAsset],
);
useEffect(() => {
if (!previewAsset) return;
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [previewAsset, handleKeyDown]);
// The canvas refocuses on any playhead activity: starting playback or a
// seek/scrub away from where the playhead sat when the preview opened
// dismisses it. openedTime is captured per preview open (previewAsset dep),
// so a stale render can never dismiss against the wrong reference time.
useEffect(() => {
if (!previewAsset) return;
const opened = usePlayerStore.getState();
const openedTime = opened.currentTime;
// Level-triggered, not edge-triggered: a preview opened while playback is
// ALREADY running (the RAF loop bypasses the store) or while a seek is
// already in flight gets no store change to react to, so evaluate the
// current state once, through the same shared predicate the subscription
// uses. openedTime is this snapshot's own currentTime, so the
// time-diverged branch can't false-positive at open — only the
// isPlaying / requestedSeekTime branches can fire here.
if (shouldDismissAssetPreview(openedTime, opened)) {
clearPreviewAsset();
return;
}
return usePlayerStore.subscribe((state) => {
if (shouldDismissAssetPreview(openedTime, state)) clearPreviewAsset();
});
}, [previewAsset, clearPreviewAsset]);
if (!previewAsset || !previewProjectId) return null;
const serveUrl = resolveMediaPreviewUrl(previewAsset, previewProjectId);
const name = basename(previewAsset);
return (
{/* Floating preview card — compact, canvas stays visible around it */}