video download panel: per-format actions (video/audio) + select mode
Reworks the download model around a format choice (video = ReClip video, audio = audioOnly) instead of a global "extract audio" toggle: - Input phase is just URL + Fetch (dropped the audio checkbox). - Each card tracks video + audio download state independently and shows a button per format (Video only when ReClip reports video formats — audio-only sources get just Audio). - Playlist top actions: "All video" (when any item has video), "All audio", and "Select". - Select mode: each card shows Video/Audio checkboxes (pick one, both, or none per item); a "Start download (N)" button runs the chosen set and shows a fake progress bar (N/M count, no real byte progress). Bulk "All video/audio" reuse the same progress bar. - Single item = large card with the two format buttons, no bulk row. VideoInfo gains `formats` so the client can tell video-capable from audio-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,15 +2,17 @@ import { useRef, useState } from 'react';
|
|||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Download, Loader2, ChevronLeft, AlertCircle, Check, Music, Folder } from 'lucide-react';
|
import { Download, Loader2, ChevronLeft, AlertCircle, Check, Music, Film, Folder } from 'lucide-react';
|
||||||
import { useFilesAPI, type VideoInfo } from '../../hooks/useFilesAPI';
|
import { useFilesAPI, type VideoInfo } from '../../hooks/useFilesAPI';
|
||||||
|
|
||||||
// Ephemeral side-panel version of the video downloader (replaces the old modal). It opens on the
|
// Ephemeral side-panel video downloader. Opens on the `download` search param (target folder) with
|
||||||
// `download` search param — the target folder — with `downloadRoot` the file-browser root. Self-contained:
|
// `downloadRoot` the file-browser root. Self-contained: prefetches metadata (ReClip via the platform
|
||||||
// it prefetches metadata (ReClip via the platform proxy), downloads entries as background jobs, and bumps
|
// proxy), then downloads per entry in a chosen FORMAT (video or audio) as background jobs, bumping the
|
||||||
// the shared `files:refresh-signal` so the browser re-lists once a file lands. Mirrors ReClip's own UI.
|
// shared `files:refresh-signal` so the browser re-lists as files land. Format = the ReClip audioOnly flag.
|
||||||
|
|
||||||
type DlPhase = 'idle' | 'downloading' | 'saving' | 'done' | 'error';
|
type Fmt = 'video' | 'audio';
|
||||||
|
type Phase = 'idle' | 'downloading' | 'saving' | 'done' | 'error';
|
||||||
|
type FmtState = { phase: Phase; error?: string };
|
||||||
type Entry = {
|
type Entry = {
|
||||||
url: string;
|
url: string;
|
||||||
status: 'loading' | 'ready' | 'error';
|
status: 'loading' | 'ready' | 'error';
|
||||||
@@ -19,9 +21,9 @@ type Entry = {
|
|||||||
duration?: number;
|
duration?: number;
|
||||||
uploader?: string;
|
uploader?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
dl: DlPhase;
|
hasVideo: boolean; // whether ReClip reported video formats (audio-only sources → audio button only)
|
||||||
dlError?: string;
|
video: FmtState;
|
||||||
filename?: string;
|
audio: FmtState;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmtDuration = (sec?: number): string => {
|
const fmtDuration = (sec?: number): string => {
|
||||||
@@ -35,114 +37,91 @@ const fmtDuration = (sec?: number): string => {
|
|||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
const sanitizeFolder = (name: string) => name.replace(/[/\\]/g, '').replace(/^\.+/, '').trim();
|
const sanitizeFolder = (name: string) => name.replace(/[/\\]/g, '').replace(/^\.+/, '').trim();
|
||||||
|
const sub = (e: Entry) => [e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ');
|
||||||
|
|
||||||
// The thumbnail contents for a card (loading skeleton spinner / error / cover art / audio icon).
|
// ── Card pieces ──
|
||||||
const Thumb = ({ e, audioOnly, size }: { e: Entry; audioOnly: boolean; size: number }) => {
|
|
||||||
|
const Thumb = ({ e, size }: { e: Entry; size: number }) => {
|
||||||
if (e.status === 'loading') return <Loader2 size={size} className="animate-spin text-duck-dark/40" />;
|
if (e.status === 'loading') return <Loader2 size={size} className="animate-spin text-duck-dark/40" />;
|
||||||
if (e.status === 'error') return <AlertCircle size={size} className="text-red-500/70" />;
|
if (e.status === 'error') return <AlertCircle size={size} className="text-red-500/70" />;
|
||||||
if (e.thumbnail && !audioOnly) return <img src={e.thumbnail} alt="" className="h-full w-full object-cover" />;
|
if (e.thumbnail) return <img src={e.thumbnail} alt="" className="h-full w-full object-cover" />;
|
||||||
return <Music size={size} className="text-duck-dark/40" />;
|
return <Music size={size} className="text-duck-dark/40" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Download control, in two flavors: `overlay` (a chip pinned in a grid thumbnail) or full-width (single).
|
// One format's download control, reflecting its state (button → spinner → check → retry).
|
||||||
const CardAction = ({ e, onDownload, overlay }: { e: Entry; onDownload: () => void; overlay?: boolean }) => {
|
const FmtButton = ({ fmt, state, onClick }: { fmt: Fmt; state: FmtState; onClick: () => void }) => {
|
||||||
if (e.dl === 'done')
|
const Icon = fmt === 'video' ? Film : Music;
|
||||||
return overlay ? (
|
const label = fmt === 'video' ? 'Video' : 'Audio';
|
||||||
<span className="absolute bottom-1 right-1 flex items-center gap-1 rounded bg-duck-teal px-1.5 py-0.5 text-[10px] font-medium text-duck-yellow">
|
const base = 'flex flex-1 items-center justify-center gap-1 rounded-md px-2 py-1.5 text-xs font-medium';
|
||||||
<Check size={11} /> Saved
|
if (state.phase === 'done')
|
||||||
|
return (
|
||||||
|
<span className={`${base} bg-duck-teal/15 text-duck-teal`}>
|
||||||
|
<Check size={13} /> {label}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center gap-1.5 rounded-md bg-duck-teal/15 px-4 py-2 text-sm font-medium text-duck-teal">
|
|
||||||
<Check size={15} /> Saved
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
if (e.dl === 'downloading' || e.dl === 'saving') {
|
if (state.phase === 'downloading' || state.phase === 'saving')
|
||||||
const label = e.dl === 'saving' ? 'Saving…' : 'Downloading…';
|
return (
|
||||||
return overlay ? (
|
<span className={`${base} bg-duck-dark/5 text-duck-dark/60`}>
|
||||||
<span className="absolute bottom-1 right-1 flex items-center gap-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] text-white">
|
<Loader2 size={13} className="animate-spin" /> {state.phase === 'saving' ? 'Saving…' : label}
|
||||||
<Loader2 size={11} className="animate-spin" /> {label}
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center gap-1.5 rounded-md bg-duck-dark/5 px-4 py-2 text-sm text-duck-dark/60">
|
|
||||||
<Loader2 size={15} className="animate-spin" /> {label}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
return (
|
||||||
const label = e.dl === 'error' ? 'Retry' : 'Download';
|
|
||||||
return overlay ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onDownload}
|
onClick={onClick}
|
||||||
className="absolute bottom-1 right-1 flex cursor-pointer items-center gap-1 rounded bg-duck-teal px-2 py-1 text-[10px] font-medium text-duck-yellow shadow-sm transition-colors hover:bg-duck-teal/90"
|
className={`${base} cursor-pointer border border-duck-teal/40 text-duck-teal transition-colors hover:bg-duck-teal/10`}
|
||||||
>
|
>
|
||||||
<Download size={11} /> {label}
|
<Icon size={13} /> {state.phase === 'error' ? 'Retry' : label}
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onDownload}
|
|
||||||
className="flex cursor-pointer items-center justify-center gap-1.5 rounded-md bg-duck-teal px-4 py-2.5 text-sm font-medium text-duck-yellow transition-colors hover:bg-duck-teal/90"
|
|
||||||
>
|
|
||||||
<Download size={15} /> {label}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sub = (e: Entry) => [e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ');
|
// The action area under a card: format buttons (normal) or format checkboxes (select mode).
|
||||||
|
const CardActions = ({
|
||||||
// Compact grid cell (playlist), with a position number badge.
|
|
||||||
const GridCard = ({
|
|
||||||
e,
|
e,
|
||||||
number,
|
mode,
|
||||||
audioOnly,
|
sel,
|
||||||
|
onToggle,
|
||||||
onDownload,
|
onDownload,
|
||||||
}: {
|
}: {
|
||||||
e: Entry;
|
e: Entry;
|
||||||
number: number;
|
mode: 'normal' | 'select';
|
||||||
audioOnly: boolean;
|
sel: { video?: boolean; audio?: boolean };
|
||||||
onDownload: () => void;
|
onToggle: (fmt: Fmt) => void;
|
||||||
}) => (
|
onDownload: (fmt: Fmt) => void;
|
||||||
<div className="flex flex-col gap-1">
|
}) => {
|
||||||
<div className="relative flex aspect-video items-center justify-center overflow-hidden rounded-md bg-duck-dark/10">
|
if (mode === 'select')
|
||||||
<Thumb e={e} audioOnly={audioOnly} size={18} />
|
return (
|
||||||
<span className="absolute left-1 top-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-white">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
{number}
|
{e.hasVideo && (
|
||||||
</span>
|
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-duck-dark/80">
|
||||||
{e.status === 'ready' && <CardAction e={e} onDownload={onDownload} overlay />}
|
<Checkbox checked={!!sel.video} onCheckedChange={() => onToggle('video')} /> Video
|
||||||
</div>
|
</label>
|
||||||
{e.status !== 'loading' && (
|
|
||||||
<>
|
|
||||||
<p className="truncate text-xs font-medium text-duck-dark" title={e.title}>
|
|
||||||
{e.status === 'error' ? 'Could not fetch' : e.title || e.url}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-[10px] text-duck-dark/50">{e.status === 'error' ? e.error || '' : sub(e)}</p>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-duck-dark/80">
|
||||||
|
<Checkbox checked={!!sel.audio} onCheckedChange={() => onToggle('audio')} /> Audio
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{e.hasVideo && <FmtButton fmt="video" state={e.video} onClick={() => onDownload('video')} />}
|
||||||
|
<FmtButton fmt="audio" state={e.audio} onClick={() => onDownload('audio')} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Large single-item card.
|
const ProgressBar = ({ done, total }: { done: number; total: number }) => (
|
||||||
const SingleCard = ({ e, audioOnly, onDownload }: { e: Entry; audioOnly: boolean; onDownload: () => void }) => (
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-duck-dark/10">
|
||||||
<div className="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-lg bg-duck-dark/10">
|
<div
|
||||||
<Thumb e={e} audioOnly={audioOnly} size={36} />
|
className="h-full rounded-full bg-duck-teal transition-all duration-300"
|
||||||
|
style={{ width: `${total ? (done / total) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{e.status === 'error' ? (
|
<span className="shrink-0 text-xs font-medium tabular-nums text-duck-dark/60">
|
||||||
<div>
|
{done}/{total}
|
||||||
<p className="font-medium text-red-500">Could not fetch</p>
|
</span>
|
||||||
<p className="break-all text-sm text-duck-dark/50">{e.error || e.url}</p>
|
|
||||||
</div>
|
|
||||||
) : e.status === 'ready' ? (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<p className="text-base font-semibold text-duck-dark" title={e.title}>
|
|
||||||
{e.title || e.url}
|
|
||||||
</p>
|
|
||||||
{sub(e) && <p className="text-sm text-duck-dark/60">{sub(e)}</p>}
|
|
||||||
</div>
|
|
||||||
<CardAction e={e} onDownload={onDownload} />
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -162,32 +141,48 @@ export const VideoDownloadPanel = () => {
|
|||||||
|
|
||||||
const [phase, setPhase] = useState<'input' | 'preview'>('input');
|
const [phase, setPhase] = useState<'input' | 'preview'>('input');
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [audioOnly, setAudioOnly] = useState(false);
|
|
||||||
const [subfolder, setSubfolder] = useState('');
|
const [subfolder, setSubfolder] = useState('');
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
const [entries, setEntries] = useState<Entry[]>([]);
|
const [entries, setEntries] = useState<Entry[]>([]);
|
||||||
const [downloadingAll, setDownloadingAll] = useState(false);
|
const [mode, setMode] = useState<'normal' | 'select'>('normal');
|
||||||
|
const [sel, setSel] = useState<Record<number, { video?: boolean; audio?: boolean }>>({});
|
||||||
|
const [bulk, setBulk] = useState<{ done: number; total: number } | null>(null);
|
||||||
const entriesRef = useRef(entries);
|
const entriesRef = useRef(entries);
|
||||||
entriesRef.current = entries;
|
entriesRef.current = entries;
|
||||||
|
|
||||||
const isPlaylist = entries.length > 1;
|
const isPlaylist = entries.length > 1;
|
||||||
const readyCount = entries.filter((e) => e.status === 'ready').length;
|
const anyReadyVideo = entries.some((e) => e.status === 'ready' && e.hasVideo);
|
||||||
|
const selCount = entries.reduce((n, e, i) => n + (sel[i]?.video && e.hasVideo ? 1 : 0) + (sel[i]?.audio ? 1 : 0), 0);
|
||||||
const folderLabel = basePath === '/' ? 'Home' : basePath.split('/').pop() || 'Home';
|
const folderLabel = basePath === '/' ? 'Home' : basePath.split('/').pop() || 'Home';
|
||||||
|
|
||||||
const patch = (i: number, p: Partial<Entry>) =>
|
const patch = (i: number, p: Partial<Entry>) =>
|
||||||
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...p } : e)));
|
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...p } : e)));
|
||||||
|
const patchFmt = (i: number, fmt: Fmt, p: Partial<FmtState>) =>
|
||||||
|
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, [fmt]: { ...e[fmt], ...p } } : e)));
|
||||||
|
|
||||||
const fetchMeta = async () => {
|
const fetchMeta = async () => {
|
||||||
const u = url.trim();
|
const u = url.trim();
|
||||||
if (!u) return;
|
if (!u) return;
|
||||||
setFetching(true);
|
setFetching(true);
|
||||||
setEntries([]);
|
setEntries([]);
|
||||||
|
setMode('normal');
|
||||||
|
setBulk(null);
|
||||||
|
setSel({});
|
||||||
|
|
||||||
let urls = [u];
|
let urls = [u];
|
||||||
if (u.includes('list=')) {
|
if (u.includes('list=')) {
|
||||||
const pl = await files.videoPlaylist(u).catch(() => null);
|
const pl = await files.videoPlaylist(u).catch(() => null);
|
||||||
if (pl?.error) {
|
if (pl?.error) {
|
||||||
setEntries([{ url: u, status: 'error', error: pl.error, dl: 'idle' }]);
|
setEntries([
|
||||||
|
{
|
||||||
|
url: u,
|
||||||
|
status: 'error',
|
||||||
|
error: pl.error,
|
||||||
|
hasVideo: false,
|
||||||
|
video: { phase: 'idle' },
|
||||||
|
audio: { phase: 'idle' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
setPhase('preview');
|
setPhase('preview');
|
||||||
setFetching(false);
|
setFetching(false);
|
||||||
return;
|
return;
|
||||||
@@ -195,13 +190,21 @@ export const VideoDownloadPanel = () => {
|
|||||||
if (pl?.urls?.length) urls = pl.urls;
|
if (pl?.urls?.length) urls = pl.urls;
|
||||||
}
|
}
|
||||||
|
|
||||||
setEntries(urls.map((v) => ({ url: v, status: 'loading', dl: 'idle' })));
|
setEntries(
|
||||||
|
urls.map((v) => ({
|
||||||
|
url: v,
|
||||||
|
status: 'loading',
|
||||||
|
hasVideo: true,
|
||||||
|
video: { phase: 'idle' },
|
||||||
|
audio: { phase: 'idle' },
|
||||||
|
})),
|
||||||
|
);
|
||||||
setPhase('preview');
|
setPhase('preview');
|
||||||
|
|
||||||
// Sequentially (ReClip runs yt-dlp per video); cards fill in as they resolve.
|
// Sequentially (ReClip runs yt-dlp per video); cards fill in as they resolve.
|
||||||
for (let i = 0; i < urls.length; i++) {
|
for (let i = 0; i < urls.length; i++) {
|
||||||
const info = await files.videoInfo(urls[i]!).catch((): VideoInfo => ({ error: 'Could not fetch info' }));
|
const info = await files.videoInfo(urls[i]!).catch((): VideoInfo => ({ error: 'Could not fetch info' }));
|
||||||
if (info.error) patch(i, { status: 'error', error: info.error });
|
if (info.error) patch(i, { status: 'error', error: info.error, hasVideo: false });
|
||||||
else
|
else
|
||||||
patch(i, {
|
patch(i, {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
@@ -209,52 +212,71 @@ export const VideoDownloadPanel = () => {
|
|||||||
thumbnail: info.thumbnail,
|
thumbnail: info.thumbnail,
|
||||||
duration: info.duration,
|
duration: info.duration,
|
||||||
uploader: info.uploader,
|
uploader: info.uploader,
|
||||||
|
hasVideo: (info.formats?.length ?? 1) > 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setFetching(false);
|
setFetching(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetDir = () => {
|
const targetDir = () => {
|
||||||
const sub = sanitizeFolder(subfolder);
|
const s = sanitizeFolder(subfolder);
|
||||||
if (!sub) return basePath;
|
if (!s) return basePath;
|
||||||
return basePath === '/' ? `/${sub}` : `${basePath}/${sub}`;
|
return basePath === '/' ? `/${s}` : `${basePath}/${s}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadEntry = async (i: number, entryUrl: string) => {
|
// Download one entry in one format (video/audio) as a background job, polling to completion.
|
||||||
patch(i, { dl: 'downloading', dlError: undefined });
|
const downloadFmt = async (i: number, fmt: Fmt) => {
|
||||||
|
patchFmt(i, fmt, { phase: 'downloading', error: undefined });
|
||||||
try {
|
try {
|
||||||
const { jobId } = await files.downloadVideo(entryUrl, targetDir(), audioOnly);
|
const { jobId } = await files.downloadVideo(entriesRef.current[i]!.url, targetDir(), fmt === 'audio');
|
||||||
const deadline = Date.now() + 60 * 60 * 1000;
|
const deadline = Date.now() + 60 * 60 * 1000;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (Date.now() > deadline) return patch(i, { dl: 'error', dlError: 'Timed out' });
|
if (Date.now() > deadline) return patchFmt(i, fmt, { phase: 'error', error: 'Timed out' });
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
const st = await files.downloadVideoStatus(jobId).catch(() => null);
|
const st = await files.downloadVideoStatus(jobId).catch(() => null);
|
||||||
if (!st) continue;
|
if (!st) continue;
|
||||||
if (st.status === 'error') return patch(i, { dl: 'error', dlError: st.error || 'Download failed' });
|
if (st.status === 'error') return patchFmt(i, fmt, { phase: 'error', error: st.error || 'Download failed' });
|
||||||
if (st.status === 'transferring') patch(i, { dl: 'saving' });
|
if (st.status === 'transferring') patchFmt(i, fmt, { phase: 'saving' });
|
||||||
if (st.status === 'done') {
|
if (st.status === 'done') {
|
||||||
patch(i, { dl: 'done', filename: st.filename });
|
patchFmt(i, fmt, { phase: 'done' });
|
||||||
setRefreshSignal((n) => n + 1);
|
setRefreshSignal((n) => n + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
patch(i, { dl: 'error', dlError: 'Could not start the download' });
|
patchFmt(i, fmt, { phase: 'error', error: 'Could not start the download' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadAll = async () => {
|
// Run a set of (entry, format) downloads sequentially, driving the top progress bar.
|
||||||
setDownloadingAll(true);
|
const runBulk = async (pairs: Array<{ i: number; fmt: Fmt }>) => {
|
||||||
const snapshot = entriesRef.current;
|
if (!pairs.length) return;
|
||||||
for (let i = 0; i < snapshot.length; i++) {
|
setBulk({ done: 0, total: pairs.length });
|
||||||
const e = snapshot[i]!;
|
for (const { i, fmt } of pairs) {
|
||||||
if (e.status === 'ready' && e.dl !== 'done' && e.dl !== 'downloading' && e.dl !== 'saving') {
|
await downloadFmt(i, fmt);
|
||||||
await downloadEntry(i, e.url);
|
setBulk((b) => (b ? { ...b, done: b.done + 1 } : b));
|
||||||
}
|
}
|
||||||
}
|
setBulk(null); // per-card "Saved" chips remain as the completion signal
|
||||||
setDownloadingAll(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readyPairs = (fmt: Fmt) =>
|
||||||
|
entries.flatMap((e, i) => (e.status === 'ready' && (fmt === 'audio' || e.hasVideo) ? [{ i, fmt }] : []));
|
||||||
|
|
||||||
|
const startSelected = () => {
|
||||||
|
const pairs: Array<{ i: number; fmt: Fmt }> = [];
|
||||||
|
entries.forEach((e, i) => {
|
||||||
|
if (sel[i]?.video && e.hasVideo) pairs.push({ i, fmt: 'video' });
|
||||||
|
if (sel[i]?.audio) pairs.push({ i, fmt: 'audio' });
|
||||||
|
});
|
||||||
|
setMode('normal');
|
||||||
|
void runBulk(pairs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSel = (i: number, fmt: Fmt) => setSel((s) => ({ ...s, [i]: { ...s[i], [fmt]: !s[i]?.[fmt] } }));
|
||||||
|
|
||||||
|
const btn =
|
||||||
|
'cursor-pointer rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3 overflow-y-auto p-3">
|
<div className="flex h-full flex-col gap-3 overflow-y-auto p-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-duck-dark/60">
|
<div className="flex items-center gap-1.5 text-xs text-duck-dark/60">
|
||||||
@@ -277,14 +299,10 @@ export const VideoDownloadPanel = () => {
|
|||||||
placeholder="https://www.youtube.com/watch?v=… or …/playlist?list=…"
|
placeholder="https://www.youtube.com/watch?v=… or …/playlist?list=…"
|
||||||
className="h-10 w-full rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50"
|
className="h-10 w-full rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50"
|
||||||
/>
|
/>
|
||||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-duck-dark/70">
|
|
||||||
<Checkbox checked={audioOnly} onCheckedChange={(v) => setAudioOnly(v === true)} />
|
|
||||||
Extract audio only (mp3)
|
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!url.trim() || fetching}
|
disabled={!url.trim() || fetching}
|
||||||
className="flex items-center justify-center gap-2 rounded-md bg-duck-teal px-4 py-2 text-sm text-duck-yellow transition-colors hover:bg-duck-teal/90 disabled:cursor-not-allowed disabled:opacity-40"
|
className={`${btn} flex items-center justify-center gap-2 bg-duck-teal text-duck-yellow hover:bg-duck-teal/90`}
|
||||||
>
|
>
|
||||||
{fetching && <Loader2 size={14} className="animate-spin" />}
|
{fetching && <Loader2 size={14} className="animate-spin" />}
|
||||||
Fetch
|
Fetch
|
||||||
@@ -300,39 +318,74 @@ export const VideoDownloadPanel = () => {
|
|||||||
>
|
>
|
||||||
<ChevronLeft size={15} /> Back
|
<ChevronLeft size={15} /> Back
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-duck-dark/50">
|
{isPlaylist && <span className="text-xs text-duck-dark/50">{entries.length} items</span>}
|
||||||
{audioOnly ? 'Audio only' : 'Video'}
|
|
||||||
{isPlaylist ? ` · ${entries.length} items` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPlaylist && (
|
{isPlaylist && (
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<input
|
<input
|
||||||
value={subfolder}
|
value={subfolder}
|
||||||
onChange={(ev) => setSubfolder(ev.target.value)}
|
onChange={(ev) => setSubfolder(ev.target.value)}
|
||||||
placeholder="Subfolder (optional) — leave blank for this folder"
|
placeholder="Subfolder (optional) — leave blank for this folder"
|
||||||
className="h-9 w-full rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50"
|
className="h-9 w-full rounded-md border border-duck-dark/20 bg-background/60 px-3 text-sm text-duck-dark outline-none placeholder:text-duck-dark/40 focus:border-duck-teal/50"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk action row (playlists only) — progress while running, otherwise the format actions. */}
|
||||||
|
{isPlaylist &&
|
||||||
|
(bulk ? (
|
||||||
|
<ProgressBar done={bulk.done} total={bulk.total} />
|
||||||
|
) : mode === 'select' ? (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void downloadAll()}
|
onClick={() => setMode('normal')}
|
||||||
disabled={downloadingAll || readyCount === 0}
|
className={`${btn} text-duck-dark/70 hover:bg-duck-dark/5`}
|
||||||
className="flex items-center justify-center gap-1.5 rounded-md bg-duck-teal px-3 py-2 text-sm text-duck-yellow transition-colors hover:bg-duck-teal/90 disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
{downloadingAll ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
|
Cancel
|
||||||
Download all
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={selCount === 0}
|
||||||
|
onClick={startSelected}
|
||||||
|
className={`${btn} bg-duck-teal text-duck-yellow hover:bg-duck-teal/90`}
|
||||||
|
>
|
||||||
|
Start download{selCount ? ` (${selCount})` : ''}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{anyReadyVideo && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void runBulk(readyPairs('video'))}
|
||||||
|
className={`${btn} flex items-center gap-1.5 bg-duck-teal text-duck-yellow hover:bg-duck-teal/90`}
|
||||||
|
>
|
||||||
|
<Film size={14} /> All video
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void runBulk(readyPairs('audio'))}
|
||||||
|
className={`${btn} flex items-center gap-1.5 bg-duck-teal text-duck-yellow hover:bg-duck-teal/90`}
|
||||||
|
>
|
||||||
|
<Music size={14} /> All audio
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSel({});
|
||||||
|
setMode('select');
|
||||||
|
}}
|
||||||
|
className={`${btn} border border-duck-teal/40 text-duck-teal hover:bg-duck-teal/10`}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{entries.length === 1 ? (
|
{entries.length === 1 ? (
|
||||||
<SingleCard
|
<SingleCard e={entries[0]!} onDownload={(fmt) => void downloadFmt(0, fmt)} />
|
||||||
e={entries[0]!}
|
|
||||||
audioOnly={audioOnly}
|
|
||||||
onDownload={() => void downloadEntry(0, entries[0]!.url)}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-3 @min-[520px]:grid-cols-3 @min-[760px]:grid-cols-4">
|
<div className="grid grid-cols-2 gap-x-2 gap-y-3 @min-[520px]:grid-cols-3 @min-[760px]:grid-cols-4">
|
||||||
{entries.map((e, i) => (
|
{entries.map((e, i) => (
|
||||||
@@ -340,8 +393,10 @@ export const VideoDownloadPanel = () => {
|
|||||||
key={`${e.url}-${i}`}
|
key={`${e.url}-${i}`}
|
||||||
e={e}
|
e={e}
|
||||||
number={i + 1}
|
number={i + 1}
|
||||||
audioOnly={audioOnly}
|
mode={mode}
|
||||||
onDownload={() => void downloadEntry(i, e.url)}
|
sel={sel[i] ?? {}}
|
||||||
|
onToggle={(fmt) => toggleSel(i, fmt)}
|
||||||
|
onDownload={(fmt) => void downloadFmt(i, fmt)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -352,3 +407,63 @@ export const VideoDownloadPanel = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Cards ──
|
||||||
|
|
||||||
|
const GridCard = ({
|
||||||
|
e,
|
||||||
|
number,
|
||||||
|
mode,
|
||||||
|
sel,
|
||||||
|
onToggle,
|
||||||
|
onDownload,
|
||||||
|
}: {
|
||||||
|
e: Entry;
|
||||||
|
number: number;
|
||||||
|
mode: 'normal' | 'select';
|
||||||
|
sel: { video?: boolean; audio?: boolean };
|
||||||
|
onToggle: (fmt: Fmt) => void;
|
||||||
|
onDownload: (fmt: Fmt) => void;
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="relative flex aspect-video items-center justify-center overflow-hidden rounded-md bg-duck-dark/10">
|
||||||
|
<Thumb e={e} size={18} />
|
||||||
|
<span className="absolute left-1 top-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-white">
|
||||||
|
{number}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{e.status !== 'loading' && (
|
||||||
|
<>
|
||||||
|
<p className="truncate text-xs font-medium text-duck-dark" title={e.title}>
|
||||||
|
{e.status === 'error' ? 'Could not fetch' : e.title || e.url}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-[10px] text-duck-dark/50">{e.status === 'error' ? e.error || '' : sub(e)}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{e.status === 'ready' && <CardActions e={e} mode={mode} sel={sel} onToggle={onToggle} onDownload={onDownload} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SingleCard = ({ e, onDownload }: { e: Entry; onDownload: (fmt: Fmt) => void }) => (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="relative flex aspect-video w-full items-center justify-center overflow-hidden rounded-lg bg-duck-dark/10">
|
||||||
|
<Thumb e={e} size={36} />
|
||||||
|
</div>
|
||||||
|
{e.status === 'error' ? (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-red-500">Could not fetch</p>
|
||||||
|
<p className="break-all text-sm text-duck-dark/50">{e.error || e.url}</p>
|
||||||
|
</div>
|
||||||
|
) : e.status === 'ready' ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-semibold text-duck-dark" title={e.title}>
|
||||||
|
{e.title || e.url}
|
||||||
|
</p>
|
||||||
|
{sub(e) && <p className="text-sm text-duck-dark/60">{sub(e)}</p>}
|
||||||
|
</div>
|
||||||
|
<CardActions e={e} mode="normal" sel={{}} onToggle={() => {}} onDownload={onDownload} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export type VideoInfo = {
|
|||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
uploader?: string;
|
uploader?: string;
|
||||||
|
formats?: Array<{ height?: number; id?: string; label?: string }>; // present ⇒ video streams available
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user