453 lines
19 KiB
TypeScript
Executable file
453 lines
19 KiB
TypeScript
Executable file
'use client';
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { PiShareFat } from 'react-icons/pi';
|
|
import { TfiDownload } from 'react-icons/tfi';
|
|
|
|
interface VideoFormat {
|
|
format_id: string;
|
|
format_note: string;
|
|
ext: string;
|
|
resolution: string;
|
|
filesize: number;
|
|
type: string;
|
|
has_audio?: boolean;
|
|
url?: string;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
FFmpeg: any;
|
|
FFmpegWASM: any;
|
|
}
|
|
}
|
|
|
|
function getQualityLabel(resolution: string): string {
|
|
const height = parseInt(resolution) || 0;
|
|
if (height >= 3840) return '4K UHD';
|
|
if (height >= 2560) return '2K QHD';
|
|
if (height >= 1920) return 'Full HD 1080p';
|
|
if (height >= 1280) return 'HD 720p';
|
|
if (height >= 854) return 'SD 480p';
|
|
if (height >= 640) return 'SD 360p';
|
|
if (height >= 426) return 'SD 240p';
|
|
if (height >= 256) return 'SD 144p';
|
|
return resolution || 'Unknown';
|
|
}
|
|
|
|
function getQualityBadge(height: number): { label: string; color: string } | null {
|
|
if (height >= 3840) return { label: '4K', color: '#ff0000' };
|
|
if (height >= 2560) return { label: '2K', color: '#ff6b00' };
|
|
if (height >= 1920) return { label: 'HD', color: '#00a0ff' };
|
|
if (height >= 1280) return { label: 'HD', color: '#00a0ff' };
|
|
return null;
|
|
}
|
|
|
|
export default function WatchActions({ videoId }: { videoId: string }) {
|
|
const [isDownloading, setIsDownloading] = useState(false);
|
|
const [showFormats, setShowFormats] = useState(false);
|
|
const [formats, setFormats] = useState<VideoFormat[]>([]);
|
|
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([]);
|
|
const [isLoadingFormats, setIsLoadingFormats] = useState(false);
|
|
const [downloadProgress, setDownloadProgress] = useState<string>('');
|
|
const [progressPercent, setProgressPercent] = useState(0);
|
|
const [ffmpegLoaded, setFfmpegLoaded] = useState(false);
|
|
const [ffmpegLoading, setFfmpegLoading] = useState(false);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const ffmpegRef = useRef<any>(null);
|
|
|
|
const loadFFmpeg = useCallback(async () => {
|
|
if (ffmpegLoaded || ffmpegLoading) return;
|
|
|
|
setFfmpegLoading(true);
|
|
setDownloadProgress('Loading video processor...');
|
|
|
|
try {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://unpkg.com/@ffmpeg/ffmpeg@0.12.7/dist/umd/ffmpeg.js';
|
|
script.async = true;
|
|
document.head.appendChild(script);
|
|
|
|
const coreScript = document.createElement('script');
|
|
coreScript.src = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js';
|
|
coreScript.async = true;
|
|
document.head.appendChild(coreScript);
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const checkLoaded = () => {
|
|
if (window.FFmpeg && window.FFmpeg.FFmpeg) {
|
|
resolve();
|
|
} else {
|
|
setTimeout(checkLoaded, 100);
|
|
}
|
|
};
|
|
checkLoaded();
|
|
});
|
|
|
|
const { FFmpeg } = window.FFmpeg;
|
|
const ffmpeg = new FFmpeg();
|
|
|
|
await ffmpeg.load({
|
|
coreURL: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.js',
|
|
wasmURL: 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd/ffmpeg-core.wasm',
|
|
});
|
|
|
|
ffmpegRef.current = ffmpeg;
|
|
setFfmpegLoaded(true);
|
|
} catch (e) {
|
|
console.error('Failed to load FFmpeg:', e);
|
|
} finally {
|
|
setFfmpegLoading(false);
|
|
}
|
|
}, [ffmpegLoaded, ffmpegLoading]);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
setShowFormats(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
const handleShare = () => {
|
|
if (typeof window !== 'undefined') {
|
|
const url = window.location.href;
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
alert('Link copied to clipboard!');
|
|
}).catch(() => {
|
|
alert('Failed to copy link');
|
|
});
|
|
}
|
|
};
|
|
|
|
const fetchFormats = async () => {
|
|
if (showFormats) {
|
|
setShowFormats(false);
|
|
return;
|
|
}
|
|
|
|
setShowFormats(true);
|
|
if (formats.length > 0) return;
|
|
|
|
setIsLoadingFormats(true);
|
|
try {
|
|
const res = await fetch(`/api/formats?v=${encodeURIComponent(videoId)}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
|
|
if (Array.isArray(data)) {
|
|
const videoFormats = data.filter((f: VideoFormat) =>
|
|
(f.type === 'video' || f.type === 'both') &&
|
|
!f.format_note?.toLowerCase().includes('storyboard') &&
|
|
f.ext === 'mp4'
|
|
).sort((a: VideoFormat, b: VideoFormat) => {
|
|
const resA = parseInt(a.resolution) || 0;
|
|
const resB = parseInt(b.resolution) || 0;
|
|
return resB - resA;
|
|
});
|
|
|
|
const audioOnly = data.filter((f: VideoFormat) =>
|
|
f.type === 'audio' || (f.resolution === 'audio only')
|
|
).sort((a: VideoFormat, b: VideoFormat) => (b.filesize || 0) - (a.filesize || 0));
|
|
|
|
setFormats(videoFormats.length > 0 ? videoFormats : data.filter((f: VideoFormat) => f.ext === 'mp4').slice(0, 10));
|
|
setAudioFormats(audioOnly);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch formats:', e);
|
|
} finally {
|
|
setIsLoadingFormats(false);
|
|
}
|
|
};
|
|
|
|
const fetchFile = async (url: string, label: string): Promise<Uint8Array> => {
|
|
setDownloadProgress(`Downloading ${label}...`);
|
|
|
|
const tryDirect = async (): Promise<Response | null> => {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
const response = await fetch(url, { signal: controller.signal, mode: 'cors' });
|
|
clearTimeout(timeoutId);
|
|
return response.ok ? response : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const tryProxy = async (): Promise<Response> => {
|
|
return fetch(`/api/proxy-file?url=${encodeURIComponent(url)}`);
|
|
};
|
|
|
|
let response = await tryDirect();
|
|
if (!response) {
|
|
setDownloadProgress(`Connecting via proxy...`);
|
|
response = await tryProxy();
|
|
}
|
|
|
|
if (!response.ok) throw new Error(`Failed to fetch ${label}`);
|
|
|
|
const contentLength = response.headers.get('content-length');
|
|
const total = contentLength ? parseInt(contentLength, 10) : 0;
|
|
let loaded = 0;
|
|
|
|
const reader = response.body?.getReader();
|
|
if (!reader) throw new Error('No reader available');
|
|
|
|
const chunks: Uint8Array[] = [];
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
chunks.push(value);
|
|
loaded += value.length;
|
|
if (total > 0) {
|
|
const percent = Math.round((loaded / total) * 100);
|
|
setProgressPercent(percent);
|
|
setDownloadProgress(`${label}: ${percent}%`);
|
|
}
|
|
}
|
|
|
|
const result = new Uint8Array(loaded);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
result.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const downloadBlob = (blob: Blob, filename: string) => {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const handleDownload = async (format?: VideoFormat) => {
|
|
setIsDownloading(true);
|
|
setShowFormats(false);
|
|
setProgressPercent(0);
|
|
setDownloadProgress('Preparing download...');
|
|
|
|
try {
|
|
const needsAudioMerge = format && !format.has_audio && format.type !== 'both';
|
|
|
|
if (!needsAudioMerge) {
|
|
setDownloadProgress('Getting download link...');
|
|
const res = await fetch(`/api/download?v=${encodeURIComponent(videoId)}${format ? `&f=${encodeURIComponent(format.format_id)}` : ''}`);
|
|
const data = await res.json();
|
|
if (data.url) {
|
|
window.open(data.url, '_blank');
|
|
setIsDownloading(false);
|
|
setDownloadProgress('');
|
|
return;
|
|
}
|
|
}
|
|
|
|
await loadFFmpeg();
|
|
|
|
if (!ffmpegRef.current) {
|
|
throw new Error('Video processor failed to load. Please try again.');
|
|
}
|
|
|
|
const ffmpeg = ffmpegRef.current;
|
|
|
|
setDownloadProgress('Fetching video...');
|
|
const videoRes = await fetch(`/api/download?v=${encodeURIComponent(videoId)}${format ? `&f=${encodeURIComponent(format.format_id)}` : ''}`);
|
|
const videoData = await videoRes.json();
|
|
|
|
if (!videoData.url) {
|
|
throw new Error(videoData.error || 'Failed to get video URL');
|
|
}
|
|
|
|
let audioUrl: string | null = null;
|
|
if (needsAudioMerge && audioFormats.length > 0) {
|
|
const audioRes = await fetch(`/api/download?v=${encodeURIComponent(videoId)}&f=${encodeURIComponent(audioFormats[0].format_id)}`);
|
|
const audioData = await audioRes.json();
|
|
audioUrl = audioData.url;
|
|
}
|
|
|
|
const videoBuffer = await fetchFile(videoData.url, 'Video');
|
|
|
|
if (audioUrl) {
|
|
setProgressPercent(0);
|
|
const audioBuffer = await fetchFile(audioUrl, 'Audio');
|
|
|
|
setDownloadProgress('Merging video & audio...');
|
|
setProgressPercent(-1);
|
|
|
|
const videoExt = format?.ext || 'mp4';
|
|
await ffmpeg.writeFile(`input.${videoExt}`, videoBuffer);
|
|
await ffmpeg.writeFile('audio.m4a', audioBuffer);
|
|
|
|
await ffmpeg.exec([
|
|
'-i', `input.${videoExt}`,
|
|
'-i', 'audio.m4a',
|
|
'-c:v', 'copy',
|
|
'-c:a', 'aac',
|
|
'-map', '0:v',
|
|
'-map', '1:a',
|
|
'-shortest',
|
|
'output.mp4'
|
|
]);
|
|
|
|
setDownloadProgress('Saving file...');
|
|
const mergedData = await ffmpeg.readFile('output.mp4');
|
|
const mergedBuffer = new Uint8Array(mergedData as ArrayBuffer);
|
|
|
|
const qualityLabel = getQualityLabel(format?.resolution || '').replace(/\s/g, '_');
|
|
const blob = new Blob([mergedBuffer], { type: 'video/mp4' });
|
|
downloadBlob(blob, `${videoId}_${qualityLabel}.mp4`);
|
|
|
|
await ffmpeg.deleteFile(`input.${videoExt}`);
|
|
await ffmpeg.deleteFile('audio.m4a');
|
|
await ffmpeg.deleteFile('output.mp4');
|
|
} else {
|
|
const qualityLabel = getQualityLabel(format?.resolution || '').replace(/\s/g, '_');
|
|
const blob = new Blob([new Uint8Array(videoBuffer)], { type: 'video/mp4' });
|
|
downloadBlob(blob, `${videoId}_${qualityLabel}.mp4`);
|
|
}
|
|
|
|
setDownloadProgress('Download complete!');
|
|
setProgressPercent(100);
|
|
} catch (e: any) {
|
|
console.error(e);
|
|
alert(e.message || 'Download failed. Please try again.');
|
|
} finally {
|
|
setTimeout(() => {
|
|
setIsDownloading(false);
|
|
setDownloadProgress('');
|
|
setProgressPercent(0);
|
|
}, 1500);
|
|
}
|
|
};
|
|
|
|
const formatFileSize = (bytes: number): string => {
|
|
if (!bytes || bytes <= 0) return '';
|
|
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
return `${(bytes / 1024).toFixed(0)} KB`;
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: 'flex', gap: '8px', position: 'relative', alignItems: 'center', flexShrink: 0 }}>
|
|
<button
|
|
type="button"
|
|
onClick={handleShare}
|
|
className="action-btn-hover"
|
|
style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--yt-hover)', border: 'none', borderRadius: '18px', padding: '0 16px', height: '36px', color: 'var(--yt-text-primary)', fontSize: '14px', fontWeight: '500', cursor: 'pointer' }}
|
|
>
|
|
<PiShareFat size={20} style={{ marginRight: '6px' }} />
|
|
Share
|
|
</button>
|
|
|
|
<div ref={menuRef} style={{ position: 'relative' }}>
|
|
<button
|
|
type="button"
|
|
onClick={fetchFormats}
|
|
disabled={isDownloading}
|
|
className="action-btn-hover"
|
|
style={{ display: 'flex', alignItems: 'center', backgroundColor: 'var(--yt-hover)', border: 'none', borderRadius: '18px', padding: '0 16px', height: '36px', color: 'var(--yt-text-primary)', fontSize: '14px', fontWeight: '500', cursor: isDownloading ? 'wait' : 'pointer', opacity: isDownloading ? 0.7 : 1, minWidth: '120px' }}
|
|
>
|
|
<TfiDownload size={18} style={{ marginRight: '6px' }} />
|
|
{isDownloading ? (
|
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
{progressPercent > 0 ? `${progressPercent}%` : ''}
|
|
<span style={{ width: '12px', height: '12px', border: '2px solid currentColor', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block' }} />
|
|
</span>
|
|
) : 'Download'}
|
|
</button>
|
|
|
|
{showFormats && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '42px',
|
|
right: 0,
|
|
backgroundColor: 'var(--yt-background)',
|
|
borderRadius: '12px',
|
|
boxShadow: 'var(--yt-shadow-lg)',
|
|
padding: '8px 0',
|
|
zIndex: 1000,
|
|
minWidth: '240px',
|
|
maxHeight: '360px',
|
|
overflowY: 'auto',
|
|
border: '1px solid var(--yt-border)',
|
|
}}>
|
|
<div style={{ padding: '10px 16px', fontSize: '13px', fontWeight: '600', color: 'var(--yt-text-primary)', borderBottom: '1px solid var(--yt-border)' }}>
|
|
Select Quality
|
|
</div>
|
|
|
|
{isLoadingFormats ? (
|
|
<div style={{ padding: '20px 16px', textAlign: 'center', color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
|
|
<span style={{ width: '20px', height: '20px', border: '2px solid var(--yt-text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', display: 'inline-block', marginRight: '8px' }} />
|
|
Loading...
|
|
</div>
|
|
) : formats.length === 0 ? (
|
|
<div style={{ padding: '16px', textAlign: 'center', color: 'var(--yt-text-secondary)', fontSize: '14px' }}>
|
|
No formats available
|
|
</div>
|
|
) : (
|
|
formats.map(f => {
|
|
const height = parseInt(f.resolution) || 0;
|
|
const badge = getQualityBadge(height);
|
|
|
|
return (
|
|
<button
|
|
key={f.format_id}
|
|
onClick={() => handleDownload(f)}
|
|
className="format-item-hover"
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
width: '100%',
|
|
padding: '12px 16px',
|
|
backgroundColor: 'transparent',
|
|
border: 'none',
|
|
color: 'var(--yt-text-primary)',
|
|
cursor: 'pointer',
|
|
fontSize: '14px',
|
|
transition: 'background-color 0.15s',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
|
{badge && (
|
|
<span style={{
|
|
fontSize: '10px',
|
|
fontWeight: '700',
|
|
color: '#fff',
|
|
background: badge.color,
|
|
padding: '3px 6px',
|
|
borderRadius: '4px',
|
|
letterSpacing: '0.5px'
|
|
}}>
|
|
{badge.label}
|
|
</span>
|
|
)}
|
|
<span style={{ fontWeight: '500' }}>{getQualityLabel(f.resolution)}</span>
|
|
</div>
|
|
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)' }}>
|
|
{formatFileSize(f.filesize) || 'Unknown size'}
|
|
</span>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{isDownloading && downloadProgress && (
|
|
<span style={{ fontSize: '12px', color: 'var(--yt-text-secondary)', minWidth: '150px' }}>
|
|
{downloadProgress}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|