feat: add video polling and inline player for ELAI-generated speaking videos

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-12 02:59:40 +04:00
parent 7dce59929d
commit b9f13fe59c

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -318,6 +318,45 @@ export default function GenerationPage() {
onError: (err: Error) => toast({ variant: "destructive", title: "Video generation failed", description: err.message }),
});
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (activeModule !== "speaking") return;
const st = getModuleState("speaking");
const pendingParts = st.speakingParts
.map((p, i) => ({ videoUrl: p.videoUrl, index: i }))
.filter((p) => p.videoUrl.startsWith("pending:"));
if (pendingParts.length === 0) {
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
return;
}
if (pollTimerRef.current) return;
pollTimerRef.current = setInterval(async () => {
const currentSt = getModuleState("speaking");
let changed = false;
const updatedParts = [...currentSt.speakingParts];
for (const pp of pendingParts) {
const part = updatedParts[pp.index];
if (!part || !part.videoUrl.startsWith("pending:")) continue;
const videoId = part.videoUrl.replace("pending:", "");
try {
const status = await mediaService.getVideoStatus(videoId);
if (status.status === "done" || status.status === "completed" || status.video_url) {
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || status.url || "" };
changed = true;
toast({ title: "Video ready!", description: "Avatar video has been generated." });
} else if (status.status === "error" || status.status === "failed") {
updatedParts[pp.index] = { ...part, videoUrl: "" };
changed = true;
toast({ variant: "destructive", title: "Video generation failed" });
}
} catch { /* poll again next interval */ }
}
if (changed) updateModuleState("speaking", { speakingParts: updatedParts });
}, 15000);
return () => { if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeModule, moduleStates]);
const submitMut = useMutation({
mutationFn: (skipApproval: boolean) => {
const modulesPayload: Record<string, unknown> = {};
@@ -858,7 +897,18 @@ export default function GenerationPage() {
{generateVideoMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Video className="h-3 w-3 mr-1" />}
Generate Video
</Button>
{part.videoUrl && <p className="text-xs text-green-600">Video: {part.videoUrl.startsWith("pending:") ? "Processing..." : "Ready"}</p>}
{part.videoUrl && part.videoUrl.startsWith("pending:") && (
<div className="flex items-center gap-1 mt-1">
<Loader2 className="h-3 w-3 animate-spin text-orange-500" />
<p className="text-xs text-orange-600">Video processing... (polling every 15s)</p>
</div>
)}
{part.videoUrl && !part.videoUrl.startsWith("pending:") && (
<div className="mt-2 space-y-1">
<p className="text-xs text-green-600">Video ready</p>
<video controls src={part.videoUrl} className="w-full rounded max-h-48" />
</div>
)}
</CollapsibleContent>
</Collapsible>
</CardContent>