383 lines
16 KiB
JavaScript
383 lines
16 KiB
JavaScript
import { Button } from '@/components/ui/button';
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import { Spinner } from '@/components/ui/spinner.js';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import useUserStore from '@/stores/UserStore';
|
|
import { Clock10Icon, Download } from 'lucide-react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
const VideoDownloadModal = ({
|
|
nonWatermarkVideosLeft = 0,
|
|
isOpen,
|
|
onClose,
|
|
ffmpegCommand,
|
|
handleDownloadButton,
|
|
isExporting,
|
|
exportProgress,
|
|
exportStatus,
|
|
videoBlob,
|
|
videoBlobFilename,
|
|
}) => {
|
|
const [showDebug, setShowDebug] = useState(false);
|
|
const [isPremiumExport, setIsPremiumExport] = useState(false);
|
|
const [estimatedTimeRemaining, setEstimatedTimeRemaining] = useState(null);
|
|
const [status, setStatus] = useState('start'); // 'start', 'processing', 'complete'
|
|
const [downloadState, setDownloadState] = useState('idle'); // 'idle', 'downloading', 'downloaded'
|
|
const [exportToken, setExportToken] = useState(null);
|
|
|
|
const exportStartTime = useRef(null);
|
|
const lastProgressTime = useRef(null);
|
|
const lastProgress = useRef(0);
|
|
|
|
const { premiumExportRequest, premiumExportComplete } = useUserStore();
|
|
|
|
const handleShareOrDownload = async () => {
|
|
if (!videoBlob || !videoBlobFilename) {
|
|
console.error('No video blob available for sharing/download');
|
|
return;
|
|
}
|
|
|
|
setDownloadState('downloading');
|
|
|
|
try {
|
|
// Check if mobile and supports navigator.share
|
|
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
|
const canShare = isMobile && navigator.share && navigator.canShare;
|
|
|
|
if (canShare) {
|
|
try {
|
|
const files = [new File([videoBlob], videoBlobFilename, { type: 'video/mp4' })];
|
|
if (navigator.canShare({ files })) {
|
|
await navigator.share({ files });
|
|
} else {
|
|
// Fallback to download if sharing files isn't supported
|
|
const url = URL.createObjectURL(videoBlob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = videoBlobFilename;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
} catch (shareError) {
|
|
console.log('Share failed, falling back to download:', shareError);
|
|
// Fallback to download
|
|
const url = URL.createObjectURL(videoBlob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = videoBlobFilename;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
} else {
|
|
// Desktop or unsupported mobile - use download
|
|
const url = URL.createObjectURL(videoBlob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = videoBlobFilename;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// Set to downloaded state
|
|
setDownloadState('downloaded');
|
|
|
|
// Reset to idle after 1.5 seconds
|
|
setTimeout(() => {
|
|
setDownloadState('idle');
|
|
}, 1500);
|
|
} catch (error) {
|
|
console.error('Download/share failed:', error);
|
|
setDownloadState('idle');
|
|
}
|
|
};
|
|
|
|
const handleExportWithoutWatermark = async () => {
|
|
setIsPremiumExport(true);
|
|
setEstimatedTimeRemaining(null);
|
|
|
|
// Call premiumExportRequest and check response
|
|
const response = await premiumExportRequest();
|
|
|
|
if (response?.error) {
|
|
// Halt the process if there's an error
|
|
setIsPremiumExport(false);
|
|
return;
|
|
}
|
|
|
|
if (response?.success) {
|
|
// Store the export token
|
|
const token = response.success.data.export_token;
|
|
console.log('Received export token:', token);
|
|
setExportToken(token);
|
|
// Continue with export if successful
|
|
setStatus('processing');
|
|
handleDownloadButton();
|
|
}
|
|
};
|
|
|
|
const handleExportWithWatermark = () => {
|
|
setIsPremiumExport(false);
|
|
setEstimatedTimeRemaining(null);
|
|
setStatus('processing');
|
|
handleDownloadButton();
|
|
};
|
|
|
|
const handleClose = async () => {
|
|
onClose();
|
|
setTimeout(() => {
|
|
setIsPremiumExport(false);
|
|
setEstimatedTimeRemaining(null);
|
|
setStatus('start');
|
|
setExportToken(null);
|
|
exportStartTime.current = null;
|
|
lastProgressTime.current = null;
|
|
lastProgress.current = 0;
|
|
}, 300);
|
|
};
|
|
|
|
// Update status based on export progress - transition to complete immediately when 100%
|
|
useEffect(() => {
|
|
if (status === 'processing' && exportProgress >= 100) {
|
|
setStatus('complete');
|
|
// Call premiumExportComplete immediately when export completes
|
|
if (isPremiumExport && exportToken) {
|
|
console.log('Calling premiumExportComplete with token:', exportToken);
|
|
premiumExportComplete(exportToken);
|
|
} else if (isPremiumExport && !exportToken) {
|
|
console.error('Premium export completed but no token available');
|
|
}
|
|
}
|
|
}, [exportProgress, status, isPremiumExport, exportToken, premiumExportComplete]);
|
|
|
|
// Calculate estimated time remaining based on progress speed
|
|
useEffect(() => {
|
|
if (status !== 'processing' || exportProgress === 0) {
|
|
exportStartTime.current = null;
|
|
lastProgressTime.current = null;
|
|
lastProgress.current = 0;
|
|
setEstimatedTimeRemaining(null);
|
|
return;
|
|
}
|
|
|
|
// Clear estimate when complete
|
|
if (exportProgress >= 100) {
|
|
setEstimatedTimeRemaining(0);
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
// Initialize timing on first progress
|
|
if (!exportStartTime.current) {
|
|
exportStartTime.current = now;
|
|
lastProgress.current = exportProgress;
|
|
console.log('Initialized timing at', exportProgress + '%');
|
|
return;
|
|
}
|
|
|
|
// Calculate every time progress changes after initial 3 seconds
|
|
const timeSinceStart = now - exportStartTime.current;
|
|
const progressDelta = exportProgress - lastProgress.current;
|
|
|
|
console.log(
|
|
'Progress:',
|
|
exportProgress + '%',
|
|
'Time since start:',
|
|
Math.round(timeSinceStart / 1000) + 's',
|
|
'Progress delta:',
|
|
progressDelta + '%',
|
|
);
|
|
|
|
if (timeSinceStart > 2000 && progressDelta > 0) {
|
|
const progressRate = progressDelta / timeSinceStart; // progress per ms
|
|
const remainingProgress = 100 - exportProgress;
|
|
const estimatedMs = remainingProgress / progressRate;
|
|
const estimatedSeconds = Math.round(estimatedMs / 1000);
|
|
|
|
console.log('Progress rate:', progressRate, 'Estimated seconds:', estimatedSeconds);
|
|
|
|
if (estimatedSeconds >= 1 && estimatedSeconds <= 600) {
|
|
setEstimatedTimeRemaining(estimatedSeconds);
|
|
console.log('Set estimated time:', estimatedSeconds + 's');
|
|
} else {
|
|
console.log('Time estimate out of range:', estimatedSeconds + 's');
|
|
}
|
|
}
|
|
}, [status, exportProgress]);
|
|
|
|
const formatTimeRemaining = (seconds) => {
|
|
if (seconds < 60) {
|
|
return `~${seconds}s remaining`;
|
|
} else {
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
return `~${minutes}m ${remainingSeconds}s remaining`;
|
|
}
|
|
};
|
|
|
|
const exportTimes = [
|
|
{
|
|
icon: '🐇',
|
|
label: 'Modern and fast devices',
|
|
time: '1-2 mins',
|
|
},
|
|
{
|
|
icon: '🐢',
|
|
label: 'Medium range devices',
|
|
time: '3-5 mins',
|
|
},
|
|
{
|
|
icon: '🥔',
|
|
label: 'Older / potato devices',
|
|
time: '>5 mins',
|
|
},
|
|
];
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={status !== 'processing' ? onClose : undefined}>
|
|
<DialogContent
|
|
className="border-2 shadow-2xl sm:max-w-lg [&>button]:hidden"
|
|
onInteractOutside={(e) => status === 'processing' && e.preventDefault()}
|
|
onEscapeKeyDown={(e) => status === 'processing' && e.preventDefault()}
|
|
>
|
|
<DialogHeader className="space-y-4">
|
|
<DialogTitle className="text-2xl font-semibold tracking-tight">Export Video</DialogTitle>
|
|
<DialogDescription className="hidden text-base leading-relaxed"></DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{status === 'start' && (
|
|
<div className="space-y-4">
|
|
<div className="bg-muted/30 rounded-xl border p-6">
|
|
<div className="flex items-start gap-3">
|
|
<div className="space-y-3">
|
|
<p className="flex items-center gap-1 font-medium">
|
|
<Clock10Icon className="h-4 w-4" /> Estimated export time
|
|
</p>
|
|
<div className="text-muted-foreground space-y-2 text-sm">
|
|
{exportTimes.map((exportTime, index) => (
|
|
<div key={index} className="grid items-center justify-between sm:flex sm:gap-3">
|
|
<div className="grid items-center text-base sm:flex sm:gap-1">
|
|
<span className="text-2xl sm:text-base">{exportTime.icon}</span>
|
|
<span>{exportTime.label}</span>
|
|
</div>
|
|
<span className="font-mono text-xs sm:text-base">{exportTime.time}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{nonWatermarkVideosLeft > 0 && (
|
|
<Button
|
|
onClick={handleExportWithoutWatermark}
|
|
className="h-14 w-full text-base font-medium shadow-md transition-all duration-200 hover:shadow-lg"
|
|
size="lg"
|
|
>
|
|
Premium Export, no watermark ({nonWatermarkVideosLeft} left)
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={handleExportWithWatermark}
|
|
variant="outline"
|
|
className="hover:bg-muted/50 h-14 w-full border-2 bg-transparent text-base font-medium transition-all duration-200"
|
|
size="lg"
|
|
>
|
|
Export
|
|
</Button>
|
|
<Button variant="link" onClick={handleClose} className="h-8 w-full">
|
|
Close
|
|
</Button>
|
|
</div>
|
|
|
|
{showDebug && <Textarea value={ffmpegCommand} readOnly className="mb-4" />}
|
|
</div>
|
|
)}
|
|
|
|
{status === 'processing' && (
|
|
<div className="space-y-8 py-4">
|
|
<div className="space-y-4 text-center">
|
|
<div className="bg-muted mx-auto flex h-16 w-16 items-center justify-center rounded-full">
|
|
<Download className="h-8 w-8 animate-pulse" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h3 className="text-xl font-semibold">Exporting...</h3>
|
|
|
|
<p className="text-muted-foreground text-sm text-wrap">
|
|
Please do not close this window while the export is in progress.
|
|
</p>
|
|
{exportStatus && <p className="text-muted-foreground text-sm text-wrap">{exportStatus}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium">Progress</span>
|
|
<span className="font-mono text-sm font-semibold">{Math.round(exportProgress)}%</span>
|
|
</div>
|
|
<Progress value={exportProgress} className="h-3" />
|
|
{estimatedTimeRemaining != null ? (
|
|
<div className="text-center">
|
|
<span className="text-muted-foreground text-xs">{formatTimeRemaining(estimatedTimeRemaining)}</span>
|
|
</div>
|
|
) : (
|
|
exportProgress != null && (
|
|
<div className="text-center">
|
|
<span className="text-muted-foreground text-xs">Calculating time remaining...</span>
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{status === 'complete' && (
|
|
<div className="space-y-6 py-8 text-center">
|
|
<div className="bg-muted mx-auto flex h-16 w-16 items-center justify-center rounded-full">
|
|
<Download className="h-8 w-8" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h3 className="text-xl font-semibold">Done!</h3>
|
|
<p className="text-muted-foreground text-sm">Your video is now ready.</p>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<Button
|
|
onClick={handleShareOrDownload}
|
|
disabled={downloadState !== 'idle'}
|
|
className="h-14 w-full text-base font-medium shadow-md transition-all duration-200 hover:shadow-lg"
|
|
size="lg"
|
|
>
|
|
{downloadState === 'downloading' && (
|
|
<>
|
|
<Spinner className="mr-2 h-4 w-4" />
|
|
Saving
|
|
</>
|
|
)}
|
|
{downloadState === 'downloaded' && (
|
|
<>
|
|
<Download className="mr-2 h-5 w-5" />
|
|
Saved!
|
|
</>
|
|
)}
|
|
{downloadState === 'idle' && (
|
|
<>
|
|
<Download className="mr-2 h-5 w-5" />
|
|
Save Video
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button variant="outline" onClick={handleClose} className="w-full" size="lg">
|
|
Close
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default VideoDownloadModal;
|