返回 JoyAI-Echo
ComposePanel.tsx
1 import { useCallback, useEffect, useRef, useState } from "react";
2 import {
3 // ArrowLeft,
4 CheckCircle2,
5 ChevronUp,
6 Download,
7 FileText,
8 Film,
9 Loader2,
10 Maximize,
11 MessageSquarePlus,
12 Minimize,
13 Pause,
14 Play,
15 RefreshCcw,
16 Sparkles,
17 Volume2,
18 VolumeX,
19 } from "lucide-react";
20
21 import { Button } from "@/components/ui/button";
22 import { downloadMedia } from "@/lib/downloadMedia";
23 import { markComposeVideoPlayed, wasComposeVideoPlayed } from "@/lib/media";
24 import { cn } from "@/lib/utils";
25 import { useClient } from "@/providers/ClientProvider";
26 import type { RenderStatus } from "@/components/longvideo/RenderOverlay";
27 import { LikeButton } from "./LikeButton";
28 import {
29 downloadStoryboardSegments,
30 type StoryboardSegment,
31 } from "./StoryboardScriptEditor";
32
33 interface ComposePanelProps {
34 renderStatus: RenderStatus;
35 composingTitle?: string;
36 composingHint?: string;
37 videoUrl?: string;
38 sessionKey?: string;
39 downloadFileName?: string;
40 progress?: number;
41 error?: string;
42 onRetry: () => void;
43 onBack?: () => void;
44 onNewChat?: () => void;
45 /** RenderOverlay 正在播放同一成片时,避免底层重复 autoPlay */
46 playbackInOverlay?: boolean;
47 storyboardSegments?: StoryboardSegment[];
48 echoRequestId?: string | null;
49 likeStatus?: number;
50 onEchoLike?: (action: 1 | 2) => Promise<void>;
51 onEchoDownloadPrompt?: () => Promise<void>;
52 }
53
54 export function ComposePanel({
55 renderStatus,
56 composingTitle,
57 composingHint,
58 videoUrl,
59 sessionKey,
60 downloadFileName,
61 progress,
62 error,
63 onRetry,
64 // onBack,
65 onNewChat,
66 playbackInOverlay = false,
67 storyboardSegments,
68 echoRequestId,
69 likeStatus,
70 onEchoLike,
71 onEchoDownloadPrompt,
72 }: ComposePanelProps) {
73 if (renderStatus === "rendering") {
74 return (
75 <ComposingState
76 progress={progress}
77 title={composingTitle}
78 hint={composingHint}
79 />
80 );
81 }
82
83 if (renderStatus === "error") {
84 return <ComposeError error={error} onRetry={onRetry} />;
85 }
86
87 if (renderStatus === "done" && videoUrl) {
88 return (
89 <ComposePlayer
90 videoUrl={videoUrl}
91 sessionKey={sessionKey}
92 downloadFileName={downloadFileName}
93 onRetry={onRetry}
94 onNewChat={onNewChat}
95 playbackInOverlay={playbackInOverlay}
96 storyboardSegments={storyboardSegments}
97 echoRequestId={echoRequestId}
98 likeStatus={likeStatus}
99 onEchoLike={onEchoLike}
100 onEchoDownloadPrompt={onEchoDownloadPrompt}
101 />
102 );
103 }
104
105 if (renderStatus === "done" && !videoUrl) {
106 return <ComposeDone onRetry={onRetry} />;
107 }
108
109 return (
110 <div className="flex h-full flex-col items-center justify-center px-8 text-center">
111 <div className="relative mb-8">
112 <div className="absolute -inset-6 rounded-3xl bg-gradient-to-br from-foreground/[0.03] to-transparent" />
113 <div className="relative grid h-16 w-16 place-items-center rounded-2xl bg-gradient-to-br from-foreground/[0.06] to-foreground/[0.02] shadow-sm ring-1 ring-inset ring-foreground/[0.08]">
114 <Film className="h-7 w-7 text-foreground/35" />
115 </div>
116 </div>
117 <p className="text-base font-semibold tracking-tight text-foreground/90">
118 Ready to assemble
119 </p>
120 <p className="mt-2.5 max-w-[280px] text-[13px] leading-relaxed text-muted-foreground/70">
121 All shots are ready for the final cut.
122 </p>
123 </div>
124 );
125 }
126
127 function ComposingState({
128 progress,
129 title,
130 hint,
131 }: {
132 progress?: number;
133 title?: string;
134 hint?: string;
135 }) {
136 const pct = progress ?? 0;
137
138 return (
139 <div className="flex h-full flex-col items-center justify-center px-8 text-center">
140 <div className="relative mb-8">
141 <div className="absolute -inset-6 rounded-3xl bg-gradient-to-br from-foreground/[0.03] to-transparent" />
142 <div className="relative grid h-16 w-16 place-items-center rounded-2xl bg-gradient-to-br from-foreground/[0.06] to-foreground/[0.02] shadow-sm ring-1 ring-inset ring-foreground/[0.08]">
143 <Loader2 className="h-7 w-7 animate-spin text-foreground/35" />
144 </div>
145 </div>
146 <p className="text-base font-semibold tracking-tight text-foreground/90">
147 {title || "Assembling final cut"}
148 </p>
149 <p className="mt-2.5 text-[13px] text-muted-foreground/70">
150 {hint || "Merging approved shots..."}
151 </p>
152 <div className="mt-6 w-60">
153 <div className="h-1 overflow-hidden rounded-full bg-foreground/[0.06]">
154 <div
155 className="h-full rounded-full bg-gradient-to-r from-foreground/15 to-foreground/25 transition-all duration-700 ease-out"
156 style={{ width: `${pct}%` }}
157 />
158 </div>
159 <span className="mt-2 block text-[11px] tabular-nums text-muted-foreground/40">
160 {Math.round(pct)}%
161 </span>
162 </div>
163 </div>
164 );
165 }
166
167 function ComposeError({
168 error,
169 onRetry,
170 }: {
171 error?: string;
172 onRetry: () => void;
173 }) {
174 return (
175 <div className="flex h-full flex-col items-center justify-center px-8 text-center">
176 <div className="relative mb-8">
177 <div className="absolute -inset-6 rounded-3xl bg-gradient-to-br from-red-500/[0.04] to-transparent" />
178 <div className="relative grid h-16 w-16 place-items-center rounded-2xl bg-gradient-to-br from-red-500/[0.08] to-red-500/[0.03] shadow-sm ring-1 ring-inset ring-red-500/[0.12]">
179 <span className="text-xl font-semibold text-red-400/60">!</span>
180 </div>
181 </div>
182 <p className="text-base font-semibold tracking-tight text-foreground/90">
183 Assembly failed
184 </p>
185 <p className="mt-2.5 max-w-[280px] text-[13px] leading-relaxed text-muted-foreground/70">
186 {error || "An error interrupted the final assembly. Please retry."}
187 </p>
188 <div className="mt-6">
189 <Button
190 onClick={onRetry}
191 className={cn(
192 "h-9 rounded-xl px-5 text-[12px] font-medium",
193 "bg-foreground text-background shadow-sm hover:bg-foreground/90",
194 "transition-all duration-200",
195 )}
196 >
197 <RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
198 Retry Assembly
199 </Button>
200 </div>
201 </div>
202 );
203 }
204
205 export function ComposePlayer({
206 videoUrl,
207 sessionKey,
208 downloadFileName,
209 onRetry,
210 onNewChat,
211 playbackInOverlay = false,
212 storyboardSegments,
213 echoRequestId,
214 likeStatus,
215 onEchoLike,
216 onEchoDownloadPrompt,
217 }: {
218 videoUrl: string;
219 sessionKey?: string;
220 downloadFileName?: string;
221 onRetry: () => void;
222 onNewChat?: () => void;
223 playbackInOverlay?: boolean;
224 storyboardSegments?: StoryboardSegment[];
225 echoRequestId?: string | null;
226 likeStatus?: number;
227 onEchoLike?: (action: 1 | 2) => Promise<void>;
228 onEchoDownloadPrompt?: () => Promise<void>;
229 }) {
230 const { token } = useClient();
231 const videoRef = useRef<HTMLVideoElement>(null);
232 const playerContainerRef = useRef<HTMLDivElement>(null);
233 const progressRef = useRef<HTMLDivElement>(null);
234 const [playing, setPlaying] = useState(false);
235 const [muted, setMuted] = useState(false);
236 const [isFullscreen, setIsFullscreen] = useState(false);
237 const [currentTime, setCurrentTime] = useState(0);
238 const [duration, setDuration] = useState(0);
239 const [downloading, setDownloading] = useState(false);
240 const shouldAutoPlay =
241 !playbackInOverlay && !wasComposeVideoPlayed(videoUrl);
242
243 useEffect(() => {
244 const v = videoRef.current;
245 if (!v || !playbackInOverlay) return;
246 v.pause();
247 setPlaying(false);
248 }, [playbackInOverlay]);
249
250 useEffect(() => {
251 const onFullscreenChange = () => {
252 setIsFullscreen(!!document.fullscreenElement);
253 };
254 document.addEventListener("fullscreenchange", onFullscreenChange);
255 return () => {
256 document.removeEventListener("fullscreenchange", onFullscreenChange);
257 };
258 }, []);
259
260 const togglePlay = useCallback((e?: React.MouseEvent) => {
261 if (e) e.stopPropagation();
262 const v = videoRef.current;
263 if (!v) return;
264 if (v.paused) {
265 v.play().catch(() => {});
266 } else {
267 v.pause();
268 }
269 }, []);
270
271 const toggleMute = useCallback(() => {
272 const v = videoRef.current;
273 if (!v) return;
274 v.muted = !v.muted;
275 setMuted(v.muted);
276 }, []);
277
278 const toggleFullscreen = useCallback(async () => {
279 const container = playerContainerRef.current;
280 const video = videoRef.current;
281 if (!container && !video) return;
282
283 try {
284 if (document.fullscreenElement) {
285 await document.exitFullscreen();
286 return;
287 }
288
289 if (container?.requestFullscreen) {
290 await container.requestFullscreen();
291 return;
292 }
293
294 // iOS Safari: only video element supports native fullscreen
295 const webkitVideo = video as HTMLVideoElement & {
296 webkitEnterFullscreen?: () => void;
297 };
298 webkitVideo.webkitEnterFullscreen?.();
299 } catch {
300 // ignore fullscreen failures (unsupported / permission denied)
301 }
302 }, []);
303
304 const handleSeek = useCallback(
305 (e: React.MouseEvent<HTMLDivElement>) => {
306 const bar = progressRef.current;
307 const v = videoRef.current;
308 if (!bar || !v || !duration) return;
309 const rect = bar.getBoundingClientRect();
310 const pct = Math.max(
311 0,
312 Math.min(1, (e.clientX - rect.left) / rect.width),
313 );
314 v.currentTime = pct * duration;
315 setCurrentTime(v.currentTime);
316 },
317 [duration],
318 );
319
320 const formatTime = (s: number) => {
321 const total = Math.round(s);
322 const m = Math.floor(total / 60);
323 const sec = total % 60;
324 return `${m}:${sec.toString().padStart(2, "0")}`;
325 };
326 // downloadMedia 内部 catch 错误,finally 统一复位按钮 loading
327 const handleDownload = useCallback(async () => {
328 if (downloading) return;
329 setDownloading(true);
330 try {
331 await downloadMedia({
332 url: videoUrl,
333 sessionKey,
334 token,
335 fileName: downloadFileName,
336 });
337 } finally {
338 setDownloading(false);
339 }
340 }, [downloading, downloadFileName, sessionKey, token, videoUrl]);
341
342 return (
343 <div className="flex h-full min-h-0 flex-col">
344 {/* header badge */}
345 <div className="flex items-center justify-center border-b border-border/30 px-4 py-2.5">
346 <span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/[0.08] px-3 py-1 text-[11px] font-medium text-emerald-600 dark:text-emerald-400 ring-1 ring-inset ring-emerald-500/[0.15]">
347 <CheckCircle2 className="h-3 w-3" />
348 Final cut ready
349 </span>
350 </div>
351
352 <div className="flex min-h-0 flex-1 flex-col items-center justify-center">
353 <div className="overflow-hidden p-4">
354 <div
355 ref={playerContainerRef}
356 className={cn(
357 "group/player relative overflow-hidden rounded-2xl border border-border/30 bg-black/95 shadow-lg",
358 "h-auto",
359 isFullscreen && "flex h-full w-full items-center justify-center rounded-none border-0",
360 )}
361 >
362 <video
363 ref={videoRef}
364 src={videoUrl}
365 autoPlay={shouldAutoPlay}
366 className={cn(
367 "h-full w-auto cursor-pointer object-contain",
368 isFullscreen && "max-h-full max-w-full",
369 )}
370 onClick={togglePlay}
371 onPlay={() => {
372 markComposeVideoPlayed(videoUrl);
373 setPlaying(true);
374 }}
375 onPause={() => setPlaying(false)}
376 onTimeUpdate={() => {
377 if (videoRef.current)
378 setCurrentTime(videoRef.current.currentTime);
379 }}
380 onLoadedMetadata={() => {
381 const v = videoRef.current;
382 if (!v) return;
383 setDuration(v.duration);
384 }}
385 />
386 <div
387 className={cn(
388 "absolute inset-0 flex cursor-pointer items-center justify-center bg-black/20 transition-opacity duration-300",
389 playing
390 ? "opacity-0 group-hover/player:opacity-100"
391 : "opacity-100",
392 )}
393 onClick={togglePlay}
394 >
395 <div className="grid h-14 w-14 place-items-center rounded-full bg-black/40 backdrop-blur-md ring-1 ring-white/10 transition-transform duration-200 hover:scale-105">
396 {playing ? (
397 <Pause className="h-5 w-5 text-white/90" />
398 ) : (
399 <Play className="ml-0.5 h-5 w-5 text-white/90" />
400 )}
401 </div>
402 </div>
403 <button
404 type="button"
405 onClick={(e) => {
406 e.stopPropagation();
407 void toggleFullscreen();
408 }}
409 className={cn(
410 "absolute bottom-3 right-3 z-10 grid h-8 w-8 place-items-center rounded-lg",
411 "bg-black/40 text-white/80 backdrop-blur-md ring-1 ring-white/10",
412 "transition-opacity duration-200 hover:bg-black/55 hover:text-white",
413 playing
414 ? "opacity-0 group-hover/player:opacity-100"
415 : "opacity-100",
416 )}
417 aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
418 >
419 {isFullscreen ? (
420 <Minimize className="h-3.5 w-3.5" />
421 ) : (
422 <Maximize className="h-3.5 w-3.5" />
423 )}
424 </button>
425 </div>
426 </div>
427 </div>
428 <>
429 {/* controls */}
430 <div className="w-full shrink-0 border-t border-border/30 px-4 py-3">
431 {/* progress bar */}
432 <div
433 ref={progressRef}
434 className={cn(
435 "group/bar relative mb-3 h-1 cursor-pointer rounded-full bg-foreground/[0.06] transition-all",
436 "hover:h-1.5",
437 )}
438 onClick={handleSeek}
439 >
440 <div
441 className="h-full rounded-full bg-foreground/25 transition-[width] duration-100"
442 style={{
443 width: duration ? `${(currentTime / duration) * 100}%` : "0%",
444 }}
445 />
446 <div
447 className="absolute top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/70 opacity-0 shadow-sm transition-opacity group-hover/bar:opacity-100"
448 style={{
449 left: duration ? `${(currentTime / duration) * 100}%` : "0%",
450 }}
451 />
452 </div>
453 </div>
454 {/* buttons row */}
455 <div className="flex items-center justify-between px-2 pr-4">
456 <div className="flex items-center gap-1.5">
457 <button
458 type="button"
459 onClick={togglePlay}
460 className="grid h-7 w-7 place-items-center rounded-lg text-foreground/50 transition-colors hover:bg-foreground/[0.05] hover:text-foreground/70"
461 >
462 {playing ? (
463 <Pause className="h-3.5 w-3.5" />
464 ) : (
465 <Play className="ml-0.5 h-3.5 w-3.5" />
466 )}
467 </button>
468 <button
469 type="button"
470 onClick={(e) => {
471 e.stopPropagation();
472 toggleMute();
473 }}
474 className="grid h-7 w-7 place-items-center rounded-lg text-foreground/50 transition-colors hover:bg-foreground/[0.05] hover:text-foreground/70"
475 >
476 {muted ? (
477 <VolumeX className="h-3.5 w-3.5" />
478 ) : (
479 <Volume2 className="h-3.5 w-3.5" />
480 )}
481 </button>
482 <button
483 type="button"
484 onClick={(e) => {
485 e.stopPropagation();
486 void toggleFullscreen();
487 }}
488 className="grid h-7 w-7 place-items-center rounded-lg text-foreground/50 transition-colors hover:bg-foreground/[0.05] hover:text-foreground/70"
489 aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
490 >
491 {isFullscreen ? (
492 <Minimize className="h-3.5 w-3.5" />
493 ) : (
494 <Maximize className="h-3.5 w-3.5" />
495 )}
496 </button>
497 <span className="ml-0.5 text-[10px] tabular-nums text-muted-foreground/40">
498 {formatTime(currentTime)} / {formatTime(duration)}
499 </span>
500 </div>
501
502 <div className="flex items-center gap-1.5">
503 <button
504 type="button"
505 onClick={(e) => {
506 e.stopPropagation();
507 onRetry();
508 }}
509 className="flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-foreground/[0.05] hover:text-foreground/70"
510 >
511 <RefreshCcw className="h-3 w-3" />
512 Reassemble
513 </button>
514 {onNewChat && (
515 <button
516 type="button"
517 onClick={(e) => {
518 e.stopPropagation();
519 onNewChat();
520 }}
521 className="flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[10px] font-medium text-muted-foreground transition-colors hover:bg-foreground/[0.05] hover:text-foreground/70"
522 >
523 <MessageSquarePlus className="h-3 w-3" />
524 New Project
525 </button>
526 )}
527 <button
528 type="button"
529 disabled={downloading}
530 onClick={(e) => {
531 e.preventDefault();
532 e.stopPropagation();
533 void handleDownload();
534 }}
535 className={cn(
536 "flex items-center gap-1 rounded-lg px-3 py-1.5",
537 "border border-border/40 bg-foreground/[0.03]",
538 "text-[10px] font-medium text-foreground/60",
539 "transition-all duration-200 hover:bg-foreground/[0.06] hover:text-foreground/80 hover:shadow-sm",
540 "disabled:pointer-events-none disabled:opacity-50",
541 )}
542 >
543 {downloading ? (
544 <Loader2 className="h-3 w-3 animate-spin" />
545 ) : (
546 <Download className="h-3 w-3" />
547 )}
548 Download
549 </button>
550 <LikeButton
551 className="text-foreground/40 hover:text-foreground/80"
552 spanClassName="bg-foreground/40 hover:bg-foreground/80"
553 echoRequestId={echoRequestId}
554 likeStatus={(likeStatus ?? 0) as 0 | 1 | 2}
555 onLike={onEchoLike}
556 />
557 </div>
558 </div>
559 <ScriptPanel
560 segments={storyboardSegments ?? []}
561 onEchoDownloadPrompt={onEchoDownloadPrompt}
562 />
563 </>
564 </div>
565 );
566 }
567
568 function ScriptPanel({
569 segments,
570 onEchoDownloadPrompt,
571 }: {
572 segments: StoryboardSegment[];
573 onEchoDownloadPrompt?: () => Promise<void>;
574 }) {
575 const [open, setOpen] = useState(false);
576
577 const handleDownload = useCallback(
578 (e: React.MouseEvent) => {
579 e.stopPropagation();
580 if (segments.length === 0) return;
581 downloadStoryboardSegments(segments);
582 void onEchoDownloadPrompt?.();
583 },
584 [onEchoDownloadPrompt, segments],
585 );
586
587 return (
588 <div
589 className={cn(
590 "shrink-0 overflow-hidden border-t border-border/30 bg-background transition-[max-height] duration-300 ease-out",
591 open ? "max-h-[40vh]" : "max-h-[42px]",
592 )}
593 >
594 <div
595 role="button"
596 tabIndex={0}
597 className="flex cursor-pointer select-none items-center justify-between px-4 py-2.5 transition-colors hover:bg-foreground/[0.03]"
598 onClick={() => setOpen((v) => !v)}
599 onKeyDown={(e) => {
600 if (e.key === "Enter" || e.key === " ") {
601 e.preventDefault();
602 setOpen((v) => !v);
603 }
604 }}
605 >
606 <div className="flex items-center gap-2">
607 <FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
608 <span className="text-[12px] font-medium tracking-wide text-muted-foreground">
609 Script
610 </span>
611 </div>
612 <div className="flex items-center gap-2">
613 <button
614 type="button"
615 onClick={handleDownload}
616 disabled={segments.length === 0}
617 className={cn(
618 "flex items-center gap-1 rounded-md px-2 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground",
619 "disabled:pointer-events-none disabled:opacity-40",
620 )}
621 >
622 <Download className="h-3 w-3" />
623 Download
624 </button>
625 <ChevronUp
626 className={cn(
627 "h-4 w-4 shrink-0 text-muted-foreground/70 transition-transform duration-300",
628 open && "rotate-180",
629 )}
630 />
631 </div>
632 </div>
633 {open ? (
634 <div className="max-h-[calc(40vh-42px)] overflow-y-auto px-4 pb-3.5 scrollbar-thin">
635 {segments.length === 0 ? (
636 <p className="py-2 text-center text-[12px] text-muted-foreground/60">
637 No shot plan available
638 </p>
639 ) : (
640 <div className="script-panel-section rounded-[10px] border border-foreground/[0.04] bg-foreground/[0.015] px-3.5 py-2.5 animate-in fade-in-0 slide-in-from-bottom-2.5 duration-300">
641 {segments.map((seg) => (
642 <p
643 key={seg.id}
644 className="whitespace-pre-wrap text-[12px] leading-[1.75] text-foreground/[0.42] [&+&]:mt-3"
645 >
646 {seg.text}
647 </p>
648 ))}
649 </div>
650 )}
651 </div>
652 ) : null}
653 </div>
654 );
655 }
656
657 function ComposeDone({ onRetry }: { onRetry: () => void }) {
658 return (
659 <div className="flex h-full flex-col items-center justify-center px-8 text-center">
660 <div className="relative mb-8">
661 <div className="absolute -inset-6 rounded-3xl bg-gradient-to-br from-foreground/[0.03] to-transparent" />
662 <div className="relative grid h-16 w-16 place-items-center rounded-2xl bg-gradient-to-br from-foreground/[0.06] to-foreground/[0.02] shadow-sm ring-1 ring-inset ring-foreground/[0.08]">
663 <Sparkles className="h-7 w-7 text-foreground/35" />
664 </div>
665 </div>
666 <p className="text-base font-semibold tracking-tight text-foreground/90">
667 Final cut ready
668 </p>
669 <p className="mt-2.5 max-w-[280px] text-[13px] leading-relaxed text-muted-foreground/70">
670 The video is complete, but no playback URL is available.
671 </p>
672 <div className="mt-6">
673 <Button
674 onClick={onRetry}
675 className={cn(
676 "h-9 rounded-xl px-5 text-[12px] font-medium",
677 "bg-foreground text-background shadow-sm hover:bg-foreground/90",
678 "transition-all duration-200",
679 )}
680 >
681 <RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
682 Reassemble
683 </Button>
684 </div>
685 </div>
686 );
687 }
688
688 lines Plain Text