返回 JoyAI-Echo
RenderOverlay.tsx
1 import { useCallback, useEffect, useRef, useState } from "react";
2 import {
3 ArrowLeft,
4 Download,
5 Loader2,
6 MessageSquarePlus,
7 Pause,
8 Play,
9 RefreshCcw,
10 Volume2,
11 VolumeX,
12 } from "lucide-react";
13
14 import { Button } from "@/components/ui/button";
15 import { downloadMedia } from "@/lib/downloadMedia";
16 import { markComposeVideoPlayed, wasComposeVideoPlayed } from "@/lib/media";
17 import { cn } from "@/lib/utils";
18 import { useClient } from "@/providers/ClientProvider";
19 // import { LikeButton } from "./LikeButton";
20
21 export type RenderStatus = "idle" | "rendering" | "done" | "error";
22
23 interface RenderOverlayProps {
24 status: RenderStatus;
25 videoUrl?: string;
26 sessionKey?: string;
27 downloadFileName?: string;
28 error?: string;
29 progress?: number;
30 onRetry: () => void;
31 onDismiss: () => void;
32 onNewChat?: () => void;
33 hideBack?: boolean;
34 echoRequestId?: string | null;
35 likeStatus?: number;
36 onEchoLike?: (action: 1 | 2) => Promise<void>;
37 }
38
39 function renderOverlayCopy() {
40 return {
41 rendering: "Assembling final cut",
42 failedTitle: "Assembly failed",
43 failedDefault: "An error interrupted assembly. Retry or return to the shot plan.",
44 retry: "Reassemble",
45 doneTitle: "Final cut ready",
46 };
47 }
48
49 function overlayVisualTheme() {
50 return {
51 elapsedHint: "Keep this page open · Elapsed",
52 textPrimary: "text-white/85",
53 textSecondary: "text-white/30",
54 textPct: "text-white/80",
55 textPctSign: "text-white/30",
56 textErrorIcon: "text-white/50",
57 barTrack: "bg-white/[0.06]",
58 particle: "bg-white/15",
59 glowOuter: "radial-gradient(circle, rgba(255,255,255,0.03) 0%, transparent 70%)",
60 glowInner: "radial-gradient(circle, rgba(255,255,255,0.04) 0%, transparent 70%)",
61 strokeTrack: "rgba(255,255,255,0.06)",
62 strokeArc: "rgba(255,255,255,0.18)",
63 strokeArc2: "rgba(255,255,255,0.08)",
64 barFill: "linear-gradient(90deg, rgba(255,255,255,0.15) 0%, rgba(255,255,255,0.35) 100%)",
65 barShimmer: "linear-gradient(90deg, transparent, rgba(255,255,255,0.25), transparent)",
66 errorBox: "border-white/[0.06] bg-white/[0.03]",
67 btnGhost: "text-white/50 hover:bg-white/[0.05] hover:text-white/70",
68 btnPrimary: "bg-white/[0.08] text-white/80 hover:bg-white/[0.12] border-white/[0.06]",
69 };
70 }
71
72 type OverlayVisualTheme = ReturnType<typeof overlayVisualTheme>;
73
74 const TICK_MS = 3000;
75
76 const overlayCSS = `
77 @keyframes render-bar-flow {
78 0% { background-position: 200% 0; }
79 100% { background-position: -200% 0; }
80 }
81 @keyframes render-glow-breathe {
82 0%, 100% { opacity: 0.3; }
83 50% { opacity: 0.5; }
84 }
85 @keyframes render-fade-in {
86 from { opacity: 0; transform: scale(0.97) translateY(10px); }
87 to { opacity: 1; transform: scale(1) translateY(0); }
88 }
89 @keyframes render-float-up {
90 0% { transform: translateY(0) scale(1); opacity: 0.35; }
91 70% { opacity: 0.15; }
92 100% { transform: translateY(-50px) scale(0.4); opacity: 0; }
93 }
94 `;
95
96 export function RenderOverlay({
97 status,
98 videoUrl,
99 sessionKey,
100 downloadFileName,
101 error,
102 progress,
103 onRetry,
104 onDismiss,
105 onNewChat,
106 hideBack,
107 // echoRequestId,
108 // likeStatus,
109 // onEchoLike,
110 }: RenderOverlayProps) {
111 if (status === "idle") return null;
112
113 const copy = renderOverlayCopy();
114 const theme = overlayVisualTheme();
115
116 return (
117 <div
118 className="fixed inset-0 z-50 flex items-center justify-center bg-[#0a0a0b]/95 backdrop-blur-xl"
119 >
120 <style>{overlayCSS}</style>
121 <>
122 {/* static film grain texture — dark overlay only */}
123 <div
124 className="pointer-events-none absolute inset-0 opacity-[0.018]"
125 style={{
126 backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
127 }}
128 />
129 <div
130 className="pointer-events-none absolute inset-0"
131 style={{
132 background:
133 "radial-gradient(ellipse 50% 35% at 50% 50%, rgba(255,255,255,0.025) 0%, transparent 70%)",
134 }}
135 />
136 </>
137 {status === "rendering" && (
138 <RenderingState
139 progress={progress}
140 renderingLabel={copy.rendering}
141 theme={theme}
142 />
143 )}
144 {status === "error" && (
145 <ErrorState
146 error={error}
147 onRetry={onRetry}
148 onDismiss={onDismiss}
149 hideBack={hideBack}
150 copy={copy}
151 theme={theme}
152 />
153 )}
154 {status === "done" && videoUrl && (
155 <VideoPlayerPanel
156 videoUrl={videoUrl}
157 sessionKey={sessionKey}
158 downloadFileName={downloadFileName}
159 onRetry={onRetry}
160 onDismiss={onDismiss}
161 onNewChat={onNewChat}
162 hideBack={hideBack}
163 retryLabel={copy.retry}
164 // echoRequestId={echoRequestId}
165 // likeStatus={likeStatus}
166 // onEchoLike={onEchoLike}
167 />
168 )}
169 {status === "done" && !videoUrl && (
170 <DonePlaceholder onDismiss={onDismiss} onRetry={onRetry} copy={copy} />
171 )}
172 </div>
173 );
174 }
175
176 /* ── Rendering State ── */
177
178 function RenderingState({
179 progress,
180 renderingLabel,
181 theme,
182 }: {
183 progress?: number;
184 renderingLabel: string;
185 theme: OverlayVisualTheme;
186 }) {
187 const [elapsed, setElapsed] = useState(0);
188 const [simPct, setSimPct] = useState(0);
189
190 useEffect(() => {
191 const t = setInterval(() => setElapsed((e) => e + 1), 1000);
192 return () => clearInterval(t);
193 }, []);
194
195 useEffect(() => {
196 if (progress != null) return;
197 const t = setInterval(() => {
198 setSimPct((p) => {
199 if (p >= 95) return p;
200 const delta = Math.random() * 8 + 4;
201 return Math.min(95, p + delta);
202 });
203 }, TICK_MS);
204 return () => clearInterval(t);
205 }, [progress]);
206
207 const pct = progress ?? simPct;
208 const mins = Math.floor(elapsed / 60);
209 const secs = elapsed % 60;
210 const timeStr =
211 mins > 0 ? `${mins}:${secs.toString().padStart(2, "0")}` : `${secs}s`;
212
213 return (
214 <div
215 className="flex flex-col items-center gap-8"
216 style={{ animation: "render-fade-in 0.6s ease-out both" }}
217 >
218 {/* animated film-frame icon */}
219 <div className="relative">
220 {/* outer glow rings — breathe in sync with TICK_MS */}
221 <div
222 className="absolute -inset-10 rounded-full"
223 style={{
224 background: theme.glowOuter,
225 animation: `render-glow-breathe ${TICK_MS}ms ease-in-out infinite`,
226 }}
227 />
228 <div
229 className="absolute -inset-6 rounded-full"
230 style={{
231 background: theme.glowInner,
232 animation: `render-glow-breathe ${TICK_MS}ms ease-in-out infinite ${TICK_MS / 3}ms`,
233 }}
234 />
235
236 {/* core icon */}
237 <div className="relative grid h-20 w-20 place-items-center">
238 {/* spinning arcs */}
239 <svg
240 className="absolute inset-0 h-full w-full animate-spin"
241 style={{ animationDuration: "10s" }}
242 viewBox="0 0 80 80"
243 >
244 <circle
245 cx="40"
246 cy="40"
247 r="36"
248 fill="none"
249 stroke={theme.strokeTrack}
250 strokeWidth="1"
251 />
252 <path
253 d="M40 4 A36 36 0 0 1 76 40"
254 fill="none"
255 stroke={theme.strokeArc}
256 strokeWidth="1.5"
257 strokeLinecap="round"
258 />
259 </svg>
260 <svg
261 className="absolute inset-0 h-full w-full animate-spin"
262 style={{
263 animationDuration: "14s",
264 animationDirection: "reverse",
265 }}
266 viewBox="0 0 80 80"
267 >
268 <path
269 d="M40 8 A32 32 0 0 0 8 40"
270 fill="none"
271 stroke={theme.strokeArc2}
272 strokeWidth="1"
273 strokeLinecap="round"
274 />
275 </svg>
276
277 {/* percentage number — transitions smoothly over the same TICK duration */}
278 <span
279 className={cn(
280 "relative text-2xl font-light tabular-nums tracking-tight",
281 theme.textPct,
282 )}
283 style={{ transition: `all ${TICK_MS * 0.8}ms ease-out` }}
284 >
285 {Math.round(pct)}
286 <span className={cn("text-sm", theme.textPctSign)}>%</span>
287 </span>
288 </div>
289 </div>
290
291 {/* text */}
292 <div className="text-center">
293 <p
294 className={cn(
295 "text-[15px] font-medium tracking-wide",
296 theme.textPrimary,
297 )}
298 >
299 {renderingLabel}
300 </p>
301 <p className={cn("mt-2 text-[12px]", theme.textSecondary)}>
302 {theme.elapsedHint} {timeStr}
303 </p>
304 </div>
305
306 {/* progress bar — transition synced to TICK_MS */}
307 <div className="w-64">
308 <div
309 className={cn("h-[3px] overflow-hidden rounded-full", theme.barTrack)}
310 >
311 <div
312 className="relative h-full rounded-full"
313 style={{
314 width: `${pct}%`,
315 background: theme.barFill,
316 transition: `width ${TICK_MS * 0.8}ms ease-out`,
317 }}
318 >
319 {/* shimmer on the bar — same cycle as TICK */}
320 <div
321 className="absolute inset-0"
322 style={{
323 background: theme.barShimmer,
324 backgroundSize: "200% 100%",
325 animation: `render-bar-flow ${TICK_MS}ms ease-in-out infinite`,
326 }}
327 />
328 </div>
329 </div>
330 </div>
331
332 {/* particles floating up — cycle = TICK_MS based */}
333 <div className="pointer-events-none absolute bottom-[38%] flex gap-8">
334 {[0, 1, 2].map((i) => (
335 <div
336 key={i}
337 className={cn("h-0.5 w-0.5 rounded-full", theme.particle)}
338 style={{
339 animation: `render-float-up ${TICK_MS + i * 500}ms ease-out infinite ${i * (TICK_MS / 3)}ms`,
340 }}
341 />
342 ))}
343 </div>
344 </div>
345 );
346 }
347
348 /* ── Error State ── */
349
350 function ErrorState({
351 error,
352 onRetry,
353 onDismiss,
354 hideBack,
355 copy,
356 theme,
357 }: {
358 error?: string;
359 onRetry: () => void;
360 onDismiss: () => void;
361 hideBack?: boolean;
362 copy: ReturnType<typeof renderOverlayCopy>;
363 theme: OverlayVisualTheme;
364 }) {
365 return (
366 <div
367 className="flex max-w-md flex-col items-center gap-6 text-center"
368 style={{ animation: "render-fade-in 0.5s ease-out both" }}
369 >
370 {/* error mark */}
371 <div className="relative">
372 <div
373 className={cn(
374 "grid h-16 w-16 place-items-center rounded-2xl border",
375 theme.errorBox,
376 )}
377 >
378 <span className={cn("text-2xl font-light", theme.textErrorIcon)}>
379 !
380 </span>
381 </div>
382 </div>
383
384 <div>
385 <p className={cn("text-[15px] font-medium", theme.textPrimary)}>
386 {copy.failedTitle}
387 </p>
388 <p
389 className={cn(
390 "mt-2 max-w-xs text-[12px] leading-relaxed",
391 theme.textSecondary,
392 )}
393 >
394 {error || copy.failedDefault}
395 </p>
396 </div>
397
398 <div className="flex items-center gap-3">
399 {!hideBack && (
400 <Button
401 variant="ghost"
402 onClick={onDismiss}
403 className={cn(
404 "h-9 rounded-lg px-5 text-[12px] font-medium",
405 theme.btnGhost,
406 )}
407 >
408 <ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
409 Back to Shots
410 </Button>
411 )}
412 <Button
413 onClick={onRetry}
414 className={cn(
415 "h-9 rounded-lg border px-5 text-[12px] font-medium",
416 theme.btnPrimary,
417 )}
418 >
419 <RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
420 {copy.retry}
421 </Button>
422 </div>
423 </div>
424 );
425 }
426
427 /* ── Done: Video Player ── */
428
429 function VideoPlayerPanel({
430 videoUrl,
431 sessionKey,
432 downloadFileName,
433 onRetry,
434 onDismiss,
435 onNewChat,
436 hideBack,
437 retryLabel,
438 // echoRequestId,
439 // likeStatus,
440 // onEchoLike,
441 }: {
442 videoUrl: string;
443 sessionKey?: string;
444 downloadFileName?: string;
445 onRetry: () => void;
446 onDismiss: () => void;
447 onNewChat?: () => void;
448 hideBack?: boolean;
449 retryLabel: string;
450 // echoRequestId?: string | null;
451 // likeStatus?: number;
452 // onEchoLike?: (action: 1 | 2) => Promise<void>;
453 }) {
454 const { token } = useClient();
455 const videoRef = useRef<HTMLVideoElement>(null);
456 const [playing, setPlaying] = useState(false);
457 const [muted, setMuted] = useState(false);
458 const [currentTime, setCurrentTime] = useState(0);
459 const [duration, setDuration] = useState(0);
460 const [seeking, setSeeking] = useState(false);
461 const [downloading, setDownloading] = useState(false);
462 const progressRef = useRef<HTMLDivElement>(null);
463 const shouldAutoPlay = !wasComposeVideoPlayed(videoUrl);
464
465 const togglePlay = useCallback((e?: React.MouseEvent) => {
466 if (e) e.stopPropagation();
467 const v = videoRef.current;
468 if (!v) return;
469 if (v.paused) {
470 v.play().catch(() => {});
471 } else {
472 v.pause();
473 }
474 }, []);
475
476 const toggleMute = useCallback(() => {
477 const v = videoRef.current;
478 if (!v) return;
479 v.muted = !v.muted;
480 setMuted(v.muted);
481 }, []);
482
483 const handleSeek = useCallback(
484 (e: React.MouseEvent<HTMLDivElement>) => {
485 const bar = progressRef.current;
486 const v = videoRef.current;
487 if (!bar || !v || !duration) return;
488 const rect = bar.getBoundingClientRect();
489 const pct = Math.max(
490 0,
491 Math.min(1, (e.clientX - rect.left) / rect.width),
492 );
493 v.currentTime = pct * duration;
494 setCurrentTime(v.currentTime);
495 },
496 [duration],
497 );
498
499 const formatTime = (s: number) => {
500 const m = Math.floor(s / 60);
501 const sec = Math.floor(s % 60);
502 return `${m}:${sec.toString().padStart(2, "0")}`;
503 };
504
505 // downloadMedia 内部 catch 错误,finally 统一复位按钮 loading
506 const handleDownload = useCallback(async () => {
507 if (downloading) return;
508 setDownloading(true);
509 try {
510 await downloadMedia({
511 url: videoUrl,
512 sessionKey,
513 token,
514 fileName: downloadFileName,
515 });
516 } finally {
517 setDownloading(false);
518 }
519 }, [downloading, downloadFileName, sessionKey, token, videoUrl]);
520
521 return (
522 <div
523 className="flex w-full max-w-4xl flex-col gap-0"
524 style={{ animation: "render-fade-in 0.6s ease-out both" }}
525 >
526 {/* top bar */}
527 <div className="flex items-center justify-between px-1 pb-3">
528 {!hideBack ? (
529 <button
530 type="button"
531 onClick={onDismiss}
532 className="flex items-center gap-1.5 rounded-lg px-2 py-1 text-[12px] font-medium text-white/40 transition-colors hover:bg-white/[0.04] hover:text-white/60"
533 >
534 <ArrowLeft className="h-3.5 w-3.5" />
535 Back
536 </button>
537 ) : (
538 <div className="w-16" />
539 )}
540 <div className="w-16" />
541 </div>
542
543 {/* video container */}
544 <div className="group/player relative overflow-hidden rounded-xl border border-white/[0.06] bg-black shadow-2xl shadow-black/50">
545 <video
546 ref={videoRef}
547 src={videoUrl}
548 autoPlay={shouldAutoPlay}
549 className="w-full cursor-pointer object-contain"
550 onClick={togglePlay}
551 onPlay={() => {
552 markComposeVideoPlayed(videoUrl);
553 setPlaying(true);
554 }}
555 onPause={() => setPlaying(false)}
556 onTimeUpdate={() => {
557 if (!seeking && videoRef.current)
558 setCurrentTime(videoRef.current.currentTime);
559 }}
560 onLoadedMetadata={() => {
561 if (videoRef.current) setDuration(videoRef.current.duration);
562 }}
563 />
564
565 {/* play/pause overlay */}
566 <div
567 className={cn(
568 "absolute inset-0 flex cursor-pointer items-center justify-center bg-black/20 transition-opacity duration-300",
569 playing
570 ? "opacity-0 group-hover/player:opacity-100"
571 : "opacity-100",
572 )}
573 onClick={togglePlay}
574 >
575 <div className="grid h-14 w-14 place-items-center rounded-full bg-black/40 backdrop-blur-sm">
576 {playing ? (
577 <Pause className="h-6 w-6 text-white/80" />
578 ) : (
579 <Play className="ml-0.5 h-6 w-6 text-white/80" />
580 )}
581 </div>
582 </div>
583 </div>
584
585 {/* custom progress bar */}
586 <div className="px-1 pt-3">
587 <div
588 ref={progressRef}
589 className="group/bar relative h-1 cursor-pointer rounded-full bg-white/[0.08] transition-all hover:h-1.5"
590 onClick={handleSeek}
591 onMouseDown={() => setSeeking(true)}
592 onMouseUp={() => setSeeking(false)}
593 >
594 <div
595 className="h-full rounded-full bg-white/30 transition-[width] duration-100"
596 style={{
597 width: duration ? `${(currentTime / duration) * 100}%` : "0%",
598 }}
599 />
600 {/* scrubber dot */}
601 <div
602 className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/80 opacity-0 shadow-sm transition-opacity group-hover/bar:opacity-100"
603 style={{
604 left: duration ? `${(currentTime / duration) * 100}%` : "0%",
605 }}
606 />
607 </div>
608
609 {/* controls row */}
610 <div className="mt-2 flex items-center justify-between">
611 <div className="flex items-center gap-3">
612 <button
613 type="button"
614 onClick={togglePlay}
615 className="grid h-8 w-8 place-items-center rounded-lg text-white/50 transition-colors hover:bg-white/[0.05] hover:text-white/70"
616 >
617 {playing ? (
618 <Pause className="h-4 w-4" />
619 ) : (
620 <Play className="ml-0.5 h-4 w-4" />
621 )}
622 </button>
623 <button
624 type="button"
625 onClick={(e) => {
626 e.stopPropagation();
627 toggleMute();
628 }}
629 className="grid h-8 w-8 place-items-center rounded-lg text-white/50 transition-colors hover:bg-white/[0.05] hover:text-white/70"
630 >
631 {muted ? (
632 <VolumeX className="h-4 w-4" />
633 ) : (
634 <Volume2 className="h-4 w-4" />
635 )}
636 </button>
637 <span className="text-[11px] tabular-nums text-white/30">
638 {formatTime(currentTime)} / {formatTime(duration)}
639 </span>
640 </div>
641
642 <div className="flex items-center gap-2">
643 <button
644 type="button"
645 onClick={(e) => {
646 e.stopPropagation();
647 onRetry();
648 }}
649 className={cn(
650 "flex items-center gap-1.5 rounded-lg px-3 py-1.5",
651 "text-[11px] font-medium text-white/40",
652 "transition-colors hover:bg-white/[0.04] hover:text-white/60",
653 )}
654 >
655 <RefreshCcw className="h-3 w-3" />
656 {retryLabel}
657 </button>
658 {onNewChat && (
659 <button
660 type="button"
661 onClick={(e) => {
662 e.stopPropagation();
663 onNewChat();
664 }}
665 className={cn(
666 "flex items-center gap-1.5 rounded-lg px-3 py-1.5",
667 "text-[11px] font-medium text-white/40",
668 "transition-colors hover:bg-white/[0.04] hover:text-white/60",
669 )}
670 >
671 <MessageSquarePlus className="h-3 w-3" />
672 New Project
673 </button>
674 )}
675 <button
676 type="button"
677 disabled={downloading}
678 onClick={(e) => {
679 e.stopPropagation();
680 void handleDownload();
681 }}
682 className={cn(
683 "flex items-center gap-1.5 rounded-lg px-3 py-1.5",
684 "border border-white/[0.08] bg-white/[0.04]",
685 "text-[11px] font-medium text-white/60",
686 "transition-colors hover:bg-white/[0.07] hover:text-white/80",
687 "disabled:pointer-events-none disabled:opacity-50",
688 )}
689 >
690 {downloading ? (
691 <Loader2 className="h-3 w-3 animate-spin" />
692 ) : (
693 <Download className="h-3 w-3" />
694 )}
695 Download
696 </button>
697 {/* <LikeButton
698 className="text-white/60 hover:text-white/80"
699 echoRequestId={echoRequestId}
700 likeStatus={(likeStatus ?? 0) as 0 | 1 | 2}
701 onLike={onEchoLike}
702 /> */}
703 </div>
704 </div>
705 </div>
706 </div>
707 );
708 }
709
710 /* ── Done without URL ── */
711
712 function DonePlaceholder({
713 onDismiss,
714 onRetry,
715 copy,
716 }: {
717 onDismiss: () => void;
718 onRetry: () => void;
719 copy: ReturnType<typeof renderOverlayCopy>;
720 }) {
721 return (
722 <div
723 className="flex flex-col items-center gap-6 text-center"
724 style={{ animation: "render-fade-in 0.5s ease-out both" }}
725 >
726 <div className="grid h-16 w-16 place-items-center rounded-2xl border border-white/[0.06] bg-white/[0.03]">
727 <span className="text-lg font-medium text-white/60">✓</span>
728 </div>
729 <div>
730 <p className="text-[15px] font-medium text-white/85">
731 {copy.doneTitle}
732 </p>
733 <p className="mt-2 text-[12px] text-white/35">
734 The video is complete, but no playback URL is available.
735 </p>
736 </div>
737 <div className="flex items-center gap-3">
738 <Button
739 variant="ghost"
740 onClick={onDismiss}
741 className="h-9 rounded-lg px-5 text-[12px] font-medium text-white/50 hover:bg-white/[0.05] hover:text-white/70"
742 >
743 Back
744 </Button>
745 <Button
746 onClick={onRetry}
747 className="h-9 rounded-lg border border-white/[0.06] bg-white/[0.08] px-5 text-[12px] font-medium text-white/80 hover:bg-white/[0.12]"
748 >
749 <RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
750 {copy.retry}
751 </Button>
752 </div>
753 </div>
754 );
755 }
756
756 lines Plain Text