feat(studio): hover-to-autoplay on render rows using shared ExpandedVideoPreview

Extracted shared ExpandedVideoPreview component (src/components/ui/)
used by both AssetsTab (video assets) and RenderQueueItem (rendered
videos). Wraps completed render rows in ExpandOnHover with the same
spring-expand + video autoplay pattern as the Compositions and Assets
tabs. No code duplication: AssetsTab video case delegates to the shared
component, RenderQueueItem reuses it with an Open action button.
This commit is contained in:
Miguel Ángel
2026-03-30 22:22:35 +02:00
parent 781bae2a44
commit 2e60005137
3 changed files with 106 additions and 16 deletions
@@ -1,4 +1,6 @@
import { memo, useCallback, useState, useEffect, useRef } from "react";
import { ExpandOnHover } from "../ui/ExpandOnHover";
import { ExpandedVideoPreview } from "../ui/ExpandedVideoPreview";
import type { RenderJob } from "./useRenderQueue";
interface RenderQueueItemProps {
@@ -91,16 +93,15 @@ export const RenderQueueItem = memo(function RenderQueueItem({
[job.id, job.filename],
);
const isClickable = job.status === "complete";
const viewSrc = `/api/render/${job.id}/view`;
return (
const row = (
<div
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
onClick={isClickable ? handleOpen : undefined}
className={[
"px-3 py-2.5 border-b border-neutral-800/30 last:border-0 transition-colors duration-150",
isClickable ? "cursor-pointer hover:bg-neutral-800/30" : "",
job.status === "complete" ? "cursor-pointer hover:bg-neutral-800/30" : "",
]
.filter(Boolean)
.join(" ")}
@@ -108,7 +109,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
<div className="flex items-center gap-2.5">
{/* Thumbnail — matches CompCard sizing (w-20 h-[45px]) */}
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
{job.status === "complete" && <RenderThumbnail src={`/api/render/${job.id}/view`} />}
{job.status === "complete" && <RenderThumbnail src={viewSrc} />}
{job.status === "rendering" && (
<div className="w-full h-full flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
@@ -139,7 +140,6 @@ export const RenderQueueItem = memo(function RenderQueueItem({
)}
</div>
{/* Progress bar + percentage */}
{job.status === "rendering" && (
<div className="mt-1">
<div className="flex items-center justify-between mb-0.5">
@@ -214,4 +214,45 @@ export const RenderQueueItem = memo(function RenderQueueItem({
</div>
</div>
);
// Completed renders: wrap in ExpandOnHover with shared ExpandedVideoPreview
// (same spring-expand pattern as AssetsTab video assets and CompositionsTab).
if (job.status !== "complete") {
return row;
}
const subtitle = [
job.durationMs ? formatDuration(job.durationMs) : null,
formatTimeAgo(job.createdAt),
]
.filter(Boolean)
.join(" · ");
return (
<ExpandOnHover
expandedContent={
<ExpandedVideoPreview
src={viewSrc}
name={job.filename}
subtitle={subtitle}
action={
<button
onClick={(e) => {
e.stopPropagation();
handleOpen();
}}
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors flex-shrink-0"
>
Open
</button>
}
/>
}
onClick={handleOpen}
expandScale={0.5}
delay={500}
>
{row}
</ExpandOnHover>
);
});
@@ -1,5 +1,6 @@
import { memo, useState, useCallback, useRef } from "react";
import { ExpandOnHover } from "../ui/ExpandOnHover";
import { ExpandedVideoPreview } from "../ui/ExpandedVideoPreview";
interface AssetsTabProps {
projectId: string;
@@ -112,22 +113,33 @@ function ExpandedAssetPreview({
isAudio: boolean;
onCopy: () => void;
}) {
if (isVideo) {
return (
<ExpandedVideoPreview
src={serveUrl}
name={name}
subtitle={asset}
action={
<button
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors flex-shrink-0"
>
Copy Path
</button>
}
/>
);
}
return (
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
<div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
{isImage && (
<img src={serveUrl} alt={name} className="max-w-full max-h-full object-contain rounded" />
)}
{isVideo && (
<video
src={serveUrl}
autoPlay
muted
loop
playsInline
className="max-w-full max-h-full object-contain rounded"
/>
)}
{isAudio && (
<div className="flex flex-col items-center gap-4">
<svg
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
interface ExpandedVideoPreviewProps {
src: string;
name: string;
subtitle: string;
action: ReactNode;
}
/**
* Shared expanded video preview used by AssetsTab (video assets) and
* the Renders panel. Autoplays the video muted+looped inside a full-bleed
* card. Caller provides the footer action slot (Copy Path, Open, etc.).
*/
export function ExpandedVideoPreview({ src, name, subtitle, action }: ExpandedVideoPreviewProps) {
return (
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
<div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
<video
src={src}
autoPlay
muted
loop
playsInline
className="max-w-full max-h-full object-contain rounded"
/>
</div>
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
<div className="min-w-0 flex-1 mr-4">
<div className="text-sm font-medium text-neutral-200 truncate">{name}</div>
<div className="text-[10px] text-neutral-600 font-mono mt-0.5 truncate">{subtitle}</div>
</div>
{action}
</div>
</div>
);
}