mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user