diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx index 45d913d2..2dd297f9 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx @@ -2,15 +2,17 @@ import { useRef, useState } from 'react'; import { useSearchParams } from 'react-router'; import { usePanelChannel } from 'hooks/usePanelChannel'; 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'; -// Ephemeral side-panel version of the video downloader (replaces the old modal). It opens on the -// `download` search param — the target folder — with `downloadRoot` the file-browser root. Self-contained: -// it prefetches metadata (ReClip via the platform proxy), downloads entries as background jobs, and bumps -// the shared `files:refresh-signal` so the browser re-lists once a file lands. Mirrors ReClip's own UI. +// Ephemeral side-panel video downloader. Opens on the `download` search param (target folder) with +// `downloadRoot` the file-browser root. Self-contained: prefetches metadata (ReClip via the platform +// proxy), then downloads per entry in a chosen FORMAT (video or audio) as background jobs, bumping the +// 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 = { url: string; status: 'loading' | 'ready' | 'error'; @@ -19,9 +21,9 @@ type Entry = { duration?: number; uploader?: string; error?: string; - dl: DlPhase; - dlError?: string; - filename?: string; + hasVideo: boolean; // whether ReClip reported video formats (audio-only sources → audio button only) + video: FmtState; + audio: FmtState; }; 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 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). -const Thumb = ({ e, audioOnly, size }: { e: Entry; audioOnly: boolean; size: number }) => { +// ── Card pieces ── + +const Thumb = ({ e, size }: { e: Entry; size: number }) => { if (e.status === 'loading') return ; if (e.status === 'error') return ; - if (e.thumbnail && !audioOnly) return ; + if (e.thumbnail) return ; return ; }; -// Download control, in two flavors: `overlay` (a chip pinned in a grid thumbnail) or full-width (single). -const CardAction = ({ e, onDownload, overlay }: { e: Entry; onDownload: () => void; overlay?: boolean }) => { - if (e.dl === 'done') - return overlay ? ( - - Saved +// One format's download control, reflecting its state (button → spinner → check → retry). +const FmtButton = ({ fmt, state, onClick }: { fmt: Fmt; state: FmtState; onClick: () => void }) => { + const Icon = fmt === 'video' ? Film : Music; + const label = fmt === 'video' ? 'Video' : 'Audio'; + const base = 'flex flex-1 items-center justify-center gap-1 rounded-md px-2 py-1.5 text-xs font-medium'; + if (state.phase === 'done') + return ( + + {label} - ) : ( -
- Saved -
); - if (e.dl === 'downloading' || e.dl === 'saving') { - const label = e.dl === 'saving' ? 'Saving…' : 'Downloading…'; - return overlay ? ( - - {label} + if (state.phase === 'downloading' || state.phase === 'saving') + return ( + + {state.phase === 'saving' ? 'Saving…' : label} - ) : ( -
- {label} -
); - } - const label = e.dl === 'error' ? 'Retry' : 'Download'; - return overlay ? ( + return ( - ) : ( - ); }; -const sub = (e: Entry) => [e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · '); - -// Compact grid cell (playlist), with a position number badge. -const GridCard = ({ +// The action area under a card: format buttons (normal) or format checkboxes (select mode). +const CardActions = ({ e, - number, - audioOnly, + mode, + sel, + onToggle, onDownload, }: { e: Entry; - number: number; - audioOnly: boolean; - onDownload: () => void; -}) => ( -
-
- - - {number} - - {e.status === 'ready' && } -
- {e.status !== 'loading' && ( - <> -

- {e.status === 'error' ? 'Could not fetch' : e.title || e.url} -

-

{e.status === 'error' ? e.error || '' : sub(e)}

- - )} -
-); - -// Large single-item card. -const SingleCard = ({ e, audioOnly, onDownload }: { e: Entry; audioOnly: boolean; onDownload: () => void }) => ( -
-
- -
- {e.status === 'error' ? ( -
-

Could not fetch

-

{e.error || e.url}

+ mode: 'normal' | 'select'; + sel: { video?: boolean; audio?: boolean }; + onToggle: (fmt: Fmt) => void; + onDownload: (fmt: Fmt) => void; +}) => { + if (mode === 'select') + return ( +
+ {e.hasVideo && ( + + )} +
- ) : e.status === 'ready' ? ( - <> -
-

- {e.title || e.url} -

- {sub(e) &&

{sub(e)}

} -
- - - ) : null} + ); + return ( +
+ {e.hasVideo && onDownload('video')} />} + onDownload('audio')} /> +
+ ); +}; + +const ProgressBar = ({ done, total }: { done: number; total: number }) => ( +
+
+
+
+ + {done}/{total} +
); @@ -162,32 +141,48 @@ export const VideoDownloadPanel = () => { const [phase, setPhase] = useState<'input' | 'preview'>('input'); const [url, setUrl] = useState(''); - const [audioOnly, setAudioOnly] = useState(false); const [subfolder, setSubfolder] = useState(''); const [fetching, setFetching] = useState(false); const [entries, setEntries] = useState([]); - const [downloadingAll, setDownloadingAll] = useState(false); + const [mode, setMode] = useState<'normal' | 'select'>('normal'); + const [sel, setSel] = useState>({}); + const [bulk, setBulk] = useState<{ done: number; total: number } | null>(null); const entriesRef = useRef(entries); entriesRef.current = entries; 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 patch = (i: number, p: Partial) => setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...p } : e))); + const patchFmt = (i: number, fmt: Fmt, p: Partial) => + setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, [fmt]: { ...e[fmt], ...p } } : e))); const fetchMeta = async () => { const u = url.trim(); if (!u) return; setFetching(true); setEntries([]); + setMode('normal'); + setBulk(null); + setSel({}); let urls = [u]; if (u.includes('list=')) { const pl = await files.videoPlaylist(u).catch(() => null); 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'); setFetching(false); return; @@ -195,13 +190,21 @@ export const VideoDownloadPanel = () => { 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'); // Sequentially (ReClip runs yt-dlp per video); cards fill in as they resolve. for (let i = 0; i < urls.length; i++) { 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 patch(i, { status: 'ready', @@ -209,52 +212,71 @@ export const VideoDownloadPanel = () => { thumbnail: info.thumbnail, duration: info.duration, uploader: info.uploader, + hasVideo: (info.formats?.length ?? 1) > 0, }); } setFetching(false); }; const targetDir = () => { - const sub = sanitizeFolder(subfolder); - if (!sub) return basePath; - return basePath === '/' ? `/${sub}` : `${basePath}/${sub}`; + const s = sanitizeFolder(subfolder); + if (!s) return basePath; + return basePath === '/' ? `/${s}` : `${basePath}/${s}`; }; - const downloadEntry = async (i: number, entryUrl: string) => { - patch(i, { dl: 'downloading', dlError: undefined }); + // Download one entry in one format (video/audio) as a background job, polling to completion. + const downloadFmt = async (i: number, fmt: Fmt) => { + patchFmt(i, fmt, { phase: 'downloading', error: undefined }); 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; 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); const st = await files.downloadVideoStatus(jobId).catch(() => null); if (!st) continue; - if (st.status === 'error') return patch(i, { dl: 'error', dlError: st.error || 'Download failed' }); - if (st.status === 'transferring') patch(i, { dl: 'saving' }); + if (st.status === 'error') return patchFmt(i, fmt, { phase: 'error', error: st.error || 'Download failed' }); + if (st.status === 'transferring') patchFmt(i, fmt, { phase: 'saving' }); if (st.status === 'done') { - patch(i, { dl: 'done', filename: st.filename }); + patchFmt(i, fmt, { phase: 'done' }); setRefreshSignal((n) => n + 1); return; } } } 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 () => { - setDownloadingAll(true); - const snapshot = entriesRef.current; - for (let i = 0; i < snapshot.length; i++) { - const e = snapshot[i]!; - if (e.status === 'ready' && e.dl !== 'done' && e.dl !== 'downloading' && e.dl !== 'saving') { - await downloadEntry(i, e.url); - } + // Run a set of (entry, format) downloads sequentially, driving the top progress bar. + const runBulk = async (pairs: Array<{ i: number; fmt: Fmt }>) => { + if (!pairs.length) return; + setBulk({ done: 0, total: pairs.length }); + for (const { i, fmt } of pairs) { + await downloadFmt(i, fmt); + setBulk((b) => (b ? { ...b, done: b.done + 1 } : b)); } - setDownloadingAll(false); + setBulk(null); // per-card "Saved" chips remain as the completion signal }; + 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 (
@@ -277,14 +299,10 @@ export const VideoDownloadPanel = () => { 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" /> - - - {audioOnly ? 'Audio only' : 'Video'} - {isPlaylist ? ` · ${entries.length} items` : ''} - + {isPlaylist && {entries.length} items}
{isPlaylist && ( -
- setSubfolder(ev.target.value)} - 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" - /> - -
+ setSubfolder(ev.target.value)} + 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" + /> )} + {/* Bulk action row (playlists only) — progress while running, otherwise the format actions. */} + {isPlaylist && + (bulk ? ( + + ) : mode === 'select' ? ( +
+ + +
+ ) : ( +
+ {anyReadyVideo && ( + + )} + + +
+ ))} +
{entries.length === 1 ? ( - void downloadEntry(0, entries[0]!.url)} - /> + void downloadFmt(0, fmt)} /> ) : (
{entries.map((e, i) => ( @@ -340,8 +393,10 @@ export const VideoDownloadPanel = () => { key={`${e.url}-${i}`} e={e} number={i + 1} - audioOnly={audioOnly} - onDownload={() => void downloadEntry(i, e.url)} + mode={mode} + sel={sel[i] ?? {}} + onToggle={(fmt) => toggleSel(i, fmt)} + onDownload={(fmt) => void downloadFmt(i, fmt)} /> ))}
@@ -352,3 +407,63 @@ export const VideoDownloadPanel = () => {
); }; + +// ── 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; +}) => ( +
+
+ + + {number} + +
+ {e.status !== 'loading' && ( + <> +

+ {e.status === 'error' ? 'Could not fetch' : e.title || e.url} +

+

{e.status === 'error' ? e.error || '' : sub(e)}

+ + )} + {e.status === 'ready' && } +
+); + +const SingleCard = ({ e, onDownload }: { e: Entry; onDownload: (fmt: Fmt) => void }) => ( +
+
+ +
+ {e.status === 'error' ? ( +
+

Could not fetch

+

{e.error || e.url}

+
+ ) : e.status === 'ready' ? ( + <> +
+

+ {e.title || e.url} +

+ {sub(e) &&

{sub(e)}

} +
+ {}} onDownload={onDownload} /> + + ) : null} +
+); diff --git a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts index e602e49e..915d0dcc 100644 --- a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts +++ b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts @@ -168,6 +168,7 @@ export type VideoInfo = { thumbnail?: string; duration?: number; uploader?: string; + formats?: Array<{ height?: number; id?: string; label?: string }>; // present ⇒ video streams available error?: string; };