file browser: video downloader as an ephemeral side panel (was a modal)
Moves the video-download UI from a modal into the same ephemeral side-panel slot the file viewer uses (double-click a video), per request. It's driven by the `download` search param (the target folder) + `downloadRoot`, exactly like the existing view/play/chat ephemeral panels. - layouts: singleDownloadLayout (files-download panel). - VideoDownloadPanel (new, apps/FileBrowser): self-contained — reads the target folder/root from the params, does its own useFilesAPI, prefetch + per-entry / download-all flow (unchanged from the dialog), and bumps the shared `files:refresh-signal` so the browser re-lists when a file lands. Header shows a "Saving to <folder>" hint. - useFileViewerPanels: register files-download (param → layout → panel + close), add download/downloadRoot to the on-refresh cleanup keys. - useFileBrowserApp: replace the showVideoDownload modal state with openVideoDownload(), which sets the download param for the current folder+root. - Toolbar + FileViewContainer trigger openVideoDownload(); drop the modal mount and delete VideoDownloadDialog.tsx. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,6 @@ import { Toolbar } from './components/Toolbar';
|
||||
import { UploadProgress } from './components/UploadProgress';
|
||||
import { FileViewContainer } from './components/FileViewContainer';
|
||||
import { TaskRunnerDialog } from './components/TaskRunnerDialog';
|
||||
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
|
||||
import { DictateDialog } from './components/DictateDialog';
|
||||
import { useFileBrowserApp } from './useFileBrowserApp';
|
||||
|
||||
@@ -26,15 +25,11 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, initialPath, defa
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
<Toolbar fileBrowserManager={fileBrowserManager} />
|
||||
<Breadcrumb
|
||||
path={fileBrowserManager.currentPath}
|
||||
onNavigate={handleNavigate} basePath={basePath} />
|
||||
<Breadcrumb path={fileBrowserManager.currentPath} onNavigate={handleNavigate} basePath={basePath} />
|
||||
<UploadProgress fileBrowserManager={fileBrowserManager} />
|
||||
<FileViewContainer fileBrowserManager={fileBrowserManager} />
|
||||
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
|
||||
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
|
||||
<DictateDialog fileBrowserManager={fileBrowserManager} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+16
-6
@@ -1,5 +1,17 @@
|
||||
import { useRef } from 'react';
|
||||
import { Loader2, Folder, ClipboardPaste, FolderPlus, FolderUp, LayoutGrid, Upload, ClipboardCopy, MessageSquare, Download, Mic } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
Folder,
|
||||
ClipboardPaste,
|
||||
FolderPlus,
|
||||
FolderUp,
|
||||
LayoutGrid,
|
||||
Upload,
|
||||
ClipboardCopy,
|
||||
MessageSquare,
|
||||
Download,
|
||||
Mic,
|
||||
} from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||
@@ -28,7 +40,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
handleChatHere,
|
||||
handleCreateDir,
|
||||
handleCreateDashboardHere,
|
||||
setShowVideoDownload,
|
||||
openVideoDownload,
|
||||
setShowDictate,
|
||||
handleUpload,
|
||||
} = fileBrowserManager;
|
||||
@@ -92,9 +104,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
})}
|
||||
</div>
|
||||
) : searchResults ? (
|
||||
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">
|
||||
No results found
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">No results found</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
@@ -145,7 +155,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
Create Dashboard here
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
|
||||
<ContextMenuItem onClick={() => openVideoDownload()} className="cursor-pointer">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download video
|
||||
</ContextMenuItem>
|
||||
|
||||
+9
-3
@@ -21,7 +21,7 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
|
||||
setShowHidden,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
setShowVideoDownload,
|
||||
openVideoDownload,
|
||||
setShowDictate,
|
||||
hiddenForced,
|
||||
} = fileBrowserManager;
|
||||
@@ -76,7 +76,7 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowVideoDownload(true)}
|
||||
onClick={() => openVideoDownload()}
|
||||
title="Download video"
|
||||
className="hidden md:block p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
@@ -126,7 +126,13 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
|
||||
onClick={() => setShowHidden((v) => !v)}
|
||||
disabled={hiddenForced}
|
||||
className={`hidden md:block p-1.5 rounded-md transition-colors ${hiddenForced ? 'opacity-30 cursor-not-allowed' : `cursor-pointer ${showHidden && !hiddenForced ? 'bg-duck-teal text-duck-yellow' : 'text-duck-teal hover:bg-duck-dark/5'}`}`}
|
||||
title={hiddenForced ? 'Hidden files not shown in home directory' : showHidden ? 'Hide hidden files' : 'Show hidden files'}
|
||||
title={
|
||||
hiddenForced
|
||||
? 'Hidden files not shown in home directory'
|
||||
: showHidden
|
||||
? 'Hide hidden files'
|
||||
: 'Show hidden files'
|
||||
}
|
||||
>
|
||||
{showHidden && !hiddenForced ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
-314
@@ -1,314 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Download, Loader2, ChevronLeft, AlertCircle, Check, Music } from 'lucide-react';
|
||||
import type { VideoInfo } from '../../../../hooks/useFilesAPI';
|
||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||
|
||||
type VideoDownloadDialogProps = {
|
||||
fileBrowserManager: UseFileBrowserAppType;
|
||||
};
|
||||
|
||||
type DlPhase = 'idle' | 'downloading' | 'saving' | 'done' | 'error';
|
||||
type Entry = {
|
||||
url: string;
|
||||
status: 'loading' | 'ready' | 'error';
|
||||
title?: string;
|
||||
thumbnail?: string;
|
||||
duration?: number;
|
||||
uploader?: string;
|
||||
error?: string;
|
||||
dl: DlPhase;
|
||||
dlError?: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
const fmtDuration = (sec?: number): string => {
|
||||
if (!sec || sec <= 0) return '';
|
||||
const s = Math.round(sec);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const ss = String(s % 60).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
// Keep a subfolder name to a single safe path segment.
|
||||
const sanitizeFolder = (name: string) => name.replace(/[/\\]/g, '').replace(/^\.+/, '').trim();
|
||||
|
||||
export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogProps) => {
|
||||
const { showVideoDownload, setShowVideoDownload, currentPath, refresh, files } = fileBrowserManager;
|
||||
|
||||
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<Entry[]>([]);
|
||||
const entriesRef = useRef(entries);
|
||||
entriesRef.current = entries;
|
||||
|
||||
const isPlaylist = entries.length > 1;
|
||||
const readyCount = entries.filter((e) => e.status === 'ready').length;
|
||||
|
||||
const reset = () => {
|
||||
setPhase('input');
|
||||
setUrl('');
|
||||
setAudioOnly(false);
|
||||
setSubfolder('');
|
||||
setFetching(false);
|
||||
setEntries([]);
|
||||
};
|
||||
|
||||
// Reset whenever the dialog is (re)opened, so a new session starts clean.
|
||||
useEffect(() => {
|
||||
if (showVideoDownload) reset();
|
||||
}, [showVideoDownload]);
|
||||
|
||||
const handleClose = () => setShowVideoDownload(false);
|
||||
|
||||
const patch = (i: number, p: Partial<Entry>) =>
|
||||
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...p } : e)));
|
||||
|
||||
// Fetch metadata: expand a playlist URL to its entries, then prefetch each one's info progressively.
|
||||
const fetchMeta = async () => {
|
||||
const u = url.trim();
|
||||
if (!u) return;
|
||||
setFetching(true);
|
||||
setEntries([]);
|
||||
|
||||
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' }]);
|
||||
setPhase('preview');
|
||||
setFetching(false);
|
||||
return;
|
||||
}
|
||||
if (pl?.urls?.length) urls = pl.urls;
|
||||
}
|
||||
|
||||
setEntries(urls.map((v) => ({ url: v, status: 'loading', dl: 'idle' })));
|
||||
setPhase('preview');
|
||||
|
||||
// Sequentially (ReClip does yt-dlp per video — parallel would hammer it); 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 });
|
||||
else
|
||||
patch(i, {
|
||||
status: 'ready',
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
duration: info.duration,
|
||||
uploader: info.uploader,
|
||||
});
|
||||
}
|
||||
setFetching(false);
|
||||
};
|
||||
|
||||
const targetDir = () => {
|
||||
const sub = sanitizeFolder(subfolder);
|
||||
if (!sub) return currentPath;
|
||||
return currentPath === '/' ? `/${sub}` : `${currentPath}/${sub}`;
|
||||
};
|
||||
|
||||
// Download one entry as a background job (the server delegates to ReClip), polling to completion.
|
||||
const downloadEntry = async (i: number, entryUrl: string) => {
|
||||
patch(i, { dl: 'downloading', dlError: undefined });
|
||||
try {
|
||||
const { jobId } = await files.downloadVideo(entryUrl, targetDir(), audioOnly);
|
||||
const deadline = Date.now() + 60 * 60 * 1000;
|
||||
for (;;) {
|
||||
if (Date.now() > deadline) return patch(i, { dl: 'error', dlError: '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 === 'done') {
|
||||
patch(i, { dl: 'done', filename: st.filename });
|
||||
void refresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
patch(i, { dl: 'error', dlError: 'Could not start the download' });
|
||||
}
|
||||
};
|
||||
|
||||
const [downloadingAll, setDownloadingAll] = useState(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
setDownloadingAll(false);
|
||||
};
|
||||
|
||||
const dlLabel = (e: Entry) =>
|
||||
e.dl === 'downloading' ? 'Downloading…' : e.dl === 'saving' ? 'Saving…' : e.dl === 'done' ? 'Saved' : '';
|
||||
|
||||
return (
|
||||
<Dialog open={showVideoDownload} onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Download video</DialogTitle>
|
||||
<DialogDescription>
|
||||
{phase === 'input'
|
||||
? 'Paste a video or playlist URL — it fetches details before downloading.'
|
||||
: 'Review and download.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{phase === 'input' ? (
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void fetchMeta();
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(ev) => setUrl(ev.target.value)}
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="cursor-pointer rounded-md px-4 py-2 text-sm text-duck-dark/70 transition-colors hover:bg-duck-dark/5"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!url.trim() || fetching}
|
||||
className="flex cursor-pointer items-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"
|
||||
>
|
||||
{fetching && <Loader2 size={14} className="animate-spin" />}
|
||||
Fetch
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhase('input')}
|
||||
className="flex cursor-pointer items-center gap-1 text-sm text-duck-dark/60 hover:text-duck-dark"
|
||||
>
|
||||
<ChevronLeft size={15} /> Back
|
||||
</button>
|
||||
<span className="text-xs text-duck-dark/50">
|
||||
{audioOnly ? 'Audio only' : 'Video'}
|
||||
{isPlaylist ? ` · ${entries.length} items` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isPlaylist && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={subfolder}
|
||||
onChange={(ev) => setSubfolder(ev.target.value)}
|
||||
placeholder="Subfolder (optional) — leave blank for this folder"
|
||||
className="h-9 min-w-0 flex-1 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void downloadAll()}
|
||||
disabled={downloadingAll || readyCount === 0}
|
||||
className="flex shrink-0 cursor-pointer items-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} />}
|
||||
Download all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex max-h-[52vh] flex-col gap-2 overflow-y-auto pr-1">
|
||||
{entries.map((e, i) => (
|
||||
<div
|
||||
key={`${e.url}-${i}`}
|
||||
className="flex items-center gap-3 rounded-md border border-duck-dark/10 bg-background/40 p-2"
|
||||
>
|
||||
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
|
||||
{e.status === 'loading' ? (
|
||||
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
|
||||
) : e.status === 'error' ? (
|
||||
<AlertCircle size={16} className="text-red-500/70" />
|
||||
) : e.thumbnail && !audioOnly ? (
|
||||
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<Music size={16} className="text-duck-dark/40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{e.status === 'loading' ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
|
||||
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
|
||||
</div>
|
||||
) : e.status === 'error' ? (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-red-500">Could not fetch</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
|
||||
{e.title || e.url}
|
||||
</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">
|
||||
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{e.dl === 'error' && <p className="truncate text-xs text-red-500">{e.dlError}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{e.status === 'ready' && (
|
||||
<div className="shrink-0">
|
||||
{e.dl === 'done' ? (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-duck-teal">
|
||||
<Check size={14} /> Saved
|
||||
</span>
|
||||
) : e.dl === 'downloading' || e.dl === 'saving' ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-duck-dark/60">
|
||||
<Loader2 size={13} className="animate-spin" /> {dlLabel(e)}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void downloadEntry(i, e.url)}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-md border border-duck-teal/40 px-2.5 py-1.5 text-xs font-medium text-duck-teal transition-colors hover:bg-duck-teal/10"
|
||||
>
|
||||
<Download size={13} />
|
||||
{e.dl === 'error' ? 'Retry' : 'Download'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -54,7 +54,6 @@ export const useFileBrowserApp = (
|
||||
entry: DirEntry;
|
||||
selectedNames?: string[];
|
||||
} | null>(null);
|
||||
const [showVideoDownload, setShowVideoDownload] = useState(false);
|
||||
const [showDictate, setShowDictate] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
const { getMatchingTasks, getMatchingTaskGroups } = useTasks();
|
||||
@@ -477,6 +476,9 @@ export const useFileBrowserApp = (
|
||||
}
|
||||
};
|
||||
|
||||
// Open the ephemeral video-download side panel, targeting the current folder + browser root.
|
||||
const openVideoDownload = () => setSearchParams({ download: currentPath, downloadRoot: rootOverride ?? homeRoot });
|
||||
|
||||
const handleCut = () => {
|
||||
const paths = selected.size > 0 ? selectedPaths() : [];
|
||||
if (paths.length === 0) return;
|
||||
@@ -665,11 +667,8 @@ export const useFileBrowserApp = (
|
||||
setRunningTask,
|
||||
getMatchingTasks,
|
||||
getMatchingTaskGroups,
|
||||
// Files API (for self-contained dialogs like the video downloader)
|
||||
files,
|
||||
// Video download
|
||||
showVideoDownload,
|
||||
setShowVideoDownload,
|
||||
// Video download (opens an ephemeral side panel)
|
||||
openVideoDownload,
|
||||
// Dictate
|
||||
showDictate,
|
||||
setShowDictate,
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
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 { 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.
|
||||
|
||||
type DlPhase = 'idle' | 'downloading' | 'saving' | 'done' | 'error';
|
||||
type Entry = {
|
||||
url: string;
|
||||
status: 'loading' | 'ready' | 'error';
|
||||
title?: string;
|
||||
thumbnail?: string;
|
||||
duration?: number;
|
||||
uploader?: string;
|
||||
error?: string;
|
||||
dl: DlPhase;
|
||||
dlError?: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
const fmtDuration = (sec?: number): string => {
|
||||
if (!sec || sec <= 0) return '';
|
||||
const s = Math.round(sec);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const ss = String(s % 60).padStart(2, '0');
|
||||
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`;
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const sanitizeFolder = (name: string) => name.replace(/[/\\]/g, '').replace(/^\.+/, '').trim();
|
||||
|
||||
export const VideoDownloadPanelHeader = () => (
|
||||
<>
|
||||
<Download className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-xs font-medium">Download video</span>
|
||||
</>
|
||||
);
|
||||
|
||||
export const VideoDownloadPanel = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const basePath = searchParams.get('download') ?? '/';
|
||||
const root = searchParams.get('downloadRoot') ?? 'home';
|
||||
const files = useFilesAPI(root);
|
||||
const [, setRefreshSignal] = usePanelChannel<number>('files:refresh-signal', 0);
|
||||
|
||||
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<Entry[]>([]);
|
||||
const [downloadingAll, setDownloadingAll] = useState(false);
|
||||
const entriesRef = useRef(entries);
|
||||
entriesRef.current = entries;
|
||||
|
||||
const isPlaylist = entries.length > 1;
|
||||
const readyCount = entries.filter((e) => e.status === 'ready').length;
|
||||
const folderLabel = basePath === '/' ? 'Home' : basePath.split('/').pop() || 'Home';
|
||||
|
||||
const patch = (i: number, p: Partial<Entry>) =>
|
||||
setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, ...p } : e)));
|
||||
|
||||
const fetchMeta = async () => {
|
||||
const u = url.trim();
|
||||
if (!u) return;
|
||||
setFetching(true);
|
||||
setEntries([]);
|
||||
|
||||
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' }]);
|
||||
setPhase('preview');
|
||||
setFetching(false);
|
||||
return;
|
||||
}
|
||||
if (pl?.urls?.length) urls = pl.urls;
|
||||
}
|
||||
|
||||
setEntries(urls.map((v) => ({ url: v, status: 'loading', dl: '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 });
|
||||
else
|
||||
patch(i, {
|
||||
status: 'ready',
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
duration: info.duration,
|
||||
uploader: info.uploader,
|
||||
});
|
||||
}
|
||||
setFetching(false);
|
||||
};
|
||||
|
||||
const targetDir = () => {
|
||||
const sub = sanitizeFolder(subfolder);
|
||||
if (!sub) return basePath;
|
||||
return basePath === '/' ? `/${sub}` : `${basePath}/${sub}`;
|
||||
};
|
||||
|
||||
const downloadEntry = async (i: number, entryUrl: string) => {
|
||||
patch(i, { dl: 'downloading', dlError: undefined });
|
||||
try {
|
||||
const { jobId } = await files.downloadVideo(entryUrl, targetDir(), audioOnly);
|
||||
const deadline = Date.now() + 60 * 60 * 1000;
|
||||
for (;;) {
|
||||
if (Date.now() > deadline) return patch(i, { dl: 'error', dlError: '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 === 'done') {
|
||||
patch(i, { dl: 'done', filename: st.filename });
|
||||
setRefreshSignal((n) => n + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
patch(i, { dl: 'error', dlError: '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);
|
||||
}
|
||||
}
|
||||
setDownloadingAll(false);
|
||||
};
|
||||
|
||||
const dlLabel = (e: Entry) =>
|
||||
e.dl === 'downloading' ? 'Downloading…' : e.dl === 'saving' ? 'Saving…' : e.dl === 'done' ? 'Saved' : '';
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Folder size={13} className="shrink-0" />
|
||||
<span className="truncate">Saving to {folderLabel}</span>
|
||||
</div>
|
||||
|
||||
{phase === 'input' ? (
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
void fetchMeta();
|
||||
}}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={url}
|
||||
onChange={(ev) => setUrl(ev.target.value)}
|
||||
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"
|
||||
/>
|
||||
<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
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{fetching && <Loader2 size={14} className="animate-spin" />}
|
||||
Fetch
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhase('input')}
|
||||
className="flex cursor-pointer items-center gap-1 text-sm text-duck-dark/60 hover:text-duck-dark"
|
||||
>
|
||||
<ChevronLeft size={15} /> Back
|
||||
</button>
|
||||
<span className="text-xs text-duck-dark/50">
|
||||
{audioOnly ? 'Audio only' : 'Video'}
|
||||
{isPlaylist ? ` · ${entries.length} items` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isPlaylist && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
value={subfolder}
|
||||
onChange={(ev) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void downloadAll()}
|
||||
disabled={downloadingAll || readyCount === 0}
|
||||
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} />}
|
||||
Download all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||
{entries.map((e, i) => (
|
||||
<div
|
||||
key={`${e.url}-${i}`}
|
||||
className="flex items-center gap-3 rounded-md border border-duck-dark/10 bg-background/40 p-2"
|
||||
>
|
||||
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
|
||||
{e.status === 'loading' ? (
|
||||
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
|
||||
) : e.status === 'error' ? (
|
||||
<AlertCircle size={16} className="text-red-500/70" />
|
||||
) : e.thumbnail && !audioOnly ? (
|
||||
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<Music size={16} className="text-duck-dark/40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{e.status === 'loading' ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
|
||||
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
|
||||
</div>
|
||||
) : e.status === 'error' ? (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-red-500">Could not fetch</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
|
||||
{e.title || e.url}
|
||||
</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">
|
||||
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{e.dl === 'error' && <p className="truncate text-xs text-red-500">{e.dlError}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{e.status === 'ready' && (
|
||||
<div className="shrink-0">
|
||||
{e.dl === 'done' ? (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-duck-teal">
|
||||
<Check size={14} /> Saved
|
||||
</span>
|
||||
) : e.dl === 'downloading' || e.dl === 'saving' ? (
|
||||
<span className="flex items-center gap-1.5 text-xs text-duck-dark/60">
|
||||
<Loader2 size={13} className="animate-spin" /> {dlLabel(e)}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void downloadEntry(i, e.url)}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-md border border-duck-teal/40 px-2.5 py-1.5 text-xs font-medium text-duck-teal transition-colors hover:bg-duck-teal/10"
|
||||
>
|
||||
<Download size={13} />
|
||||
{e.dl === 'error' ? 'Retry' : 'Download'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -28,6 +28,12 @@ export const singleChatLayout: LayoutNode = {
|
||||
appType: null,
|
||||
};
|
||||
|
||||
export const singleDownloadLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'files-download',
|
||||
appType: null,
|
||||
};
|
||||
|
||||
export const viewerWithEphemeralSplitLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'files-viewer-group',
|
||||
|
||||
@@ -2,11 +2,31 @@ import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import type { EphemeralPanels } from '../../components/Workspace';
|
||||
import { FileViewerHeader, FileViewerBody } from '../../apps/FileViewer';
|
||||
import { singleViewerLayout, singleCliampLayout, viewerWithEphemeralLayout, viewerWithEphemeralSplitLayout, singleChatLayout } from './layouts';
|
||||
import {
|
||||
singleViewerLayout,
|
||||
singleCliampLayout,
|
||||
viewerWithEphemeralLayout,
|
||||
viewerWithEphemeralSplitLayout,
|
||||
singleChatLayout,
|
||||
singleDownloadLayout,
|
||||
} from './layouts';
|
||||
import { ViewerProvider, EphemeralProvider, Ephemeral2Provider, ChatEphemeralBody } from './Providers';
|
||||
import { CliampPanelHeader, CliampPanelBody } from '../../apps/FileBrowser/CliampPanel';
|
||||
import { VideoDownloadPanel, VideoDownloadPanelHeader } from '../../apps/FileBrowser/VideoDownloadPanel';
|
||||
|
||||
const EPHEMERAL_KEYS = ['view', 'ephemeral', 'ephemeralRoot', 'ephemeral2', 'ephemeral2Root', 'ephemeral2Auto', 'chatContext', 'chatType', 'play'];
|
||||
const EPHEMERAL_KEYS = [
|
||||
'view',
|
||||
'ephemeral',
|
||||
'ephemeralRoot',
|
||||
'ephemeral2',
|
||||
'ephemeral2Root',
|
||||
'ephemeral2Auto',
|
||||
'chatContext',
|
||||
'chatType',
|
||||
'play',
|
||||
'download',
|
||||
'downloadRoot',
|
||||
];
|
||||
|
||||
export const useFileViewerPanels = (): EphemeralPanels | null => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -30,16 +50,19 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
|
||||
const ephemeral2Path = searchParams.get('ephemeral2');
|
||||
const chatContext = searchParams.get('chatContext');
|
||||
const playPath = searchParams.get('play');
|
||||
const downloadPath = searchParams.get('download');
|
||||
|
||||
const layout = playPath
|
||||
? singleCliampLayout
|
||||
: chatContext
|
||||
? singleChatLayout
|
||||
: viewPath && ephemeralPath && ephemeral2Path
|
||||
? viewerWithEphemeralSplitLayout
|
||||
: viewPath && ephemeralPath
|
||||
? viewerWithEphemeralLayout
|
||||
: singleViewerLayout;
|
||||
const layout = downloadPath
|
||||
? singleDownloadLayout
|
||||
: playPath
|
||||
? singleCliampLayout
|
||||
: chatContext
|
||||
? singleChatLayout
|
||||
: viewPath && ephemeralPath && ephemeral2Path
|
||||
? viewerWithEphemeralSplitLayout
|
||||
: viewPath && ephemeralPath
|
||||
? viewerWithEphemeralLayout
|
||||
: singleViewerLayout;
|
||||
|
||||
const onCloseViewer = useCallback(() => setSearchParams({}), [setSearchParams]);
|
||||
|
||||
@@ -90,6 +113,17 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const onCloseDownload = useCallback(
|
||||
() =>
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('download');
|
||||
next.delete('downloadRoot');
|
||||
return next;
|
||||
}),
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
'files-cliamp': {
|
||||
@@ -119,12 +153,17 @@ export const useFileViewerPanels = (): EphemeralPanels | null => {
|
||||
component: ChatEphemeralBody,
|
||||
onClose: onCloseChat,
|
||||
},
|
||||
'files-download': {
|
||||
header: VideoDownloadPanelHeader,
|
||||
component: VideoDownloadPanel,
|
||||
onClose: onCloseDownload,
|
||||
},
|
||||
}),
|
||||
[onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat, onClosePlay],
|
||||
[onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat, onClosePlay, onCloseDownload],
|
||||
);
|
||||
|
||||
if (!viewPath && !chatContext && !playPath) return null;
|
||||
const onClose = playPath ? onClosePlay : onCloseViewer;
|
||||
if (!viewPath && !chatContext && !playPath && !downloadPath) return null;
|
||||
const onClose = downloadPath ? onCloseDownload : playPath ? onClosePlay : onCloseViewer;
|
||||
return { layout, components, defaultBaseSize: 40, onClose };
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user