From 362b806c8afbea70cde2228e8a6130bfc5befbdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 29 Jul 2026 14:42:27 +0000 Subject: [PATCH] file browser: restore the inline video-download modal, drop the side panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the video-download UI from the ephemeral side panel back to the modal (VideoDownloadDialog) that predated it — the "download video" button opens the modal again and downloads inline via /download-video, exactly as before. The six wiring files had no non-download changes since the modal→panel conversion, so they're restored verbatim from that commit's parent; the panel file is removed. The inline ReClip routes it uses are untouched. Co-Authored-By: Claude Opus 4.8 --- .../FileBrowserApp/FileBrowserApp.tsx | 7 +- .../components/FileViewContainer.tsx | 22 +- .../components/Toolbar/Toolbar.tsx | 12 +- .../components/VideoDownloadDialog.tsx | 314 ++++++++ .../FileBrowserApp/useFileBrowserApp.ts | 11 +- .../apps/FileBrowser/VideoDownloadPanel.tsx | 710 ------------------ .../src/hooks/useFileViewerPanels/layouts.ts | 6 - .../useFileViewerPanels.tsx | 67 +- 8 files changed, 349 insertions(+), 800 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx delete mode 100644 src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx index 2786a67c..f22bf1d5 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/FileBrowserApp.tsx @@ -3,6 +3,7 @@ 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'; @@ -25,11 +26,15 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, initialPath, defa return (
- + +
); }; + diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx index dc2bd46d..ec965e77 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/FileViewContainer.tsx @@ -1,17 +1,5 @@ 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'; @@ -40,7 +28,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps handleChatHere, handleCreateDir, handleCreateDashboardHere, - openVideoDownload, + setShowVideoDownload, setShowDictate, handleUpload, } = fileBrowserManager; @@ -104,7 +92,9 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps })} ) : searchResults ? ( -
No results found
+
+ No results found +
) : null} ) : ( @@ -155,7 +145,7 @@ export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps Create Dashboard here - openVideoDownload()} className="cursor-pointer"> + setShowVideoDownload(true)} className="cursor-pointer"> Download video diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/Toolbar.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/Toolbar.tsx index 26ba6736..f48f5101 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/Toolbar.tsx +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/Toolbar/Toolbar.tsx @@ -21,7 +21,7 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => { setShowHidden, viewMode, setViewMode, - openVideoDownload, + setShowVideoDownload, setShowDictate, hiddenForced, } = fileBrowserManager; @@ -76,7 +76,7 @@ export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => { ) : ( <> diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx new file mode 100644 index 00000000..5539661d --- /dev/null +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/components/VideoDownloadDialog.tsx @@ -0,0 +1,314 @@ +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([]); + 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) => + 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 ( + !open && handleClose()}> + + + Download video + + {phase === 'input' + ? 'Paste a video or playlist URL — it fetches details before downloading.' + : 'Review and download.'} + + + + {phase === 'input' ? ( +
{ + ev.preventDefault(); + void fetchMeta(); + }} + className="flex flex-col gap-4" + > + 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" + /> + +
+ + +
+
+ ) : ( +
+
+ + + {audioOnly ? 'Audio only' : 'Video'} + {isPlaylist ? ` · ${entries.length} items` : ''} + +
+ + {isPlaylist && ( +
+ 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" + /> + +
+ )} + +
+ {entries.map((e, i) => ( +
+
+ {e.status === 'loading' ? ( + + ) : e.status === 'error' ? ( + + ) : e.thumbnail && !audioOnly ? ( + + ) : ( + + )} +
+ +
+ {e.status === 'loading' ? ( +
+
+
+
+ ) : e.status === 'error' ? ( + <> +

Could not fetch

+

{e.error || e.url}

+ + ) : ( + <> +

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

+

+ {[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')} +

+ {e.dl === 'error' &&

{e.dlError}

} + + )} +
+ + {e.status === 'ready' && ( +
+ {e.dl === 'done' ? ( + + Saved + + ) : e.dl === 'downloading' || e.dl === 'saving' ? ( + + {dlLabel(e)} + + ) : ( + + )} +
+ )} +
+ ))} +
+
+ )} + +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 2d90d749..67d0ff50 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -54,6 +54,7 @@ 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(); @@ -476,9 +477,6 @@ 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; @@ -667,8 +665,11 @@ export const useFileBrowserApp = ( setRunningTask, getMatchingTasks, getMatchingTaskGroups, - // Video download (opens an ephemeral side panel) - openVideoDownload, + // Files API (for self-contained dialogs like the video downloader) + files, + // Video download + showVideoDownload, + setShowVideoDownload, // Dictate showDictate, setShowDictate, diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx b/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx deleted file mode 100644 index eb3e3a00..00000000 --- a/src/workspaces/officerdev/src/apps/FileBrowser/VideoDownloadPanel.tsx +++ /dev/null @@ -1,710 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { useNavigate, useSearchParams } from 'react-router'; -import { usePanelChannel } from 'hooks/usePanelChannel'; -import { useClient } from 'hooks/useClient'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - Download, - Loader2, - ChevronLeft, - AlertCircle, - Check, - Music, - Film, - Folder, - ExternalLink, - CheckCircle2, -} from 'lucide-react'; -import { useFilesAPI, type VideoInfo } from '../../hooks/useFilesAPI'; - -// Compact progress shape mirrored from the download-job executor (done = kept/saved, failed = skipped). -type JobCounts = { done: number; failed: number; total: number }; -type JobProgress = { - phase: 'expanding' | 'metadata' | 'download' | 'done'; - meta: JobCounts; - dl: JobCounts; - current?: string; -}; - -// 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 Fmt = 'video' | 'audio'; -type Phase = 'idle' | 'downloading' | 'saving' | 'done' | 'error'; -type FmtState = { phase: Phase; error?: string }; -type Entry = { - url: string; - status: 'loading' | 'ready' | 'error'; - title?: string; - thumbnail?: string; - duration?: number; - uploader?: string; - error?: string; - hasVideo: boolean; // whether ReClip reported video formats (audio-only sources → audio button only) - video: FmtState; - audio: FmtState; -}; - -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(); -const sub = (e: Entry) => [e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · '); - -// ── Card pieces ── - -const Thumb = ({ e, size }: { e: Entry; size: number }) => { - if (e.status === 'loading') return ; - if (e.status === 'error') return ; - if (e.thumbnail) return ; - return ; -}; - -// 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} - - ); - if (state.phase === 'downloading' || state.phase === 'saving') - return ( - - {state.phase === 'saving' ? 'Saving…' : label} - - ); - return ( - - ); -}; - -// The action area under a card: format buttons (normal) or format checkboxes (select mode). -const CardActions = ({ - e, - mode, - sel, - onToggle, - onDownload, -}: { - e: Entry; - mode: 'normal' | 'select'; - sel: { video?: boolean; audio?: boolean }; - onToggle: (fmt: Fmt) => void; - onDownload: (fmt: Fmt) => void; -}) => { - if (mode === 'select') - return ( -
- {e.hasVideo && ( - - )} - -
- ); - return ( -
- {e.hasVideo && onDownload('video')} />} - onDownload('audio')} /> -
- ); -}; - -const ProgressBar = ({ done, total }: { done: number; total: number }) => ( -
-
-
-
- - {done}/{total} - -
-); - -export const VideoDownloadPanelHeader = () => ( - <> - - Download video - -); - -export const VideoDownloadPanel = () => { - const [searchParams] = useSearchParams(); - const basePath = searchParams.get('download') ?? '/'; - const root = searchParams.get('downloadRoot') ?? 'home'; - const files = useFilesAPI(root); - const client = useClient(); - const navigate = useNavigate(); - const [, setRefreshSignal] = usePanelChannel('files:refresh-signal', 0); - - const [phase, setPhase] = useState<'input' | 'decide' | 'preview' | 'job'>('input'); - const [url, setUrl] = useState(''); - const [expandedUrls, setExpandedUrls] = useState([]); // playlist items (count + inline) - const [subfolder, setSubfolder] = useState(''); - const [fetching, setFetching] = useState(false); - const [inputError, setInputError] = useState(''); - const [entries, setEntries] = useState([]); - const [mode, setMode] = useState<'normal' | 'select'>('normal'); - const [sel, setSel] = useState>({}); - const [bulk, setBulk] = useState<{ done: number; total: number } | null>(null); - const [jobFormat, setJobFormat] = useState('audio'); - const [jobId, setJobId] = useState(null); - const [jobProg, setJobProg] = useState(null); - const [jobStatus, setJobStatus] = useState('running'); - const entriesRef = useRef(entries); - entriesRef.current = entries; - const lastDlDone = useRef(0); - - const isPlaylist = entries.length > 1; - 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))); - - // Fetch = expand. A playlist goes to the decision screen (inline vs job) now that we know the count; a - // single video goes straight to the inline card. - const onFetch = async () => { - const u = url.trim(); - if (!u) return; - setInputError(''); - setEntries([]); - setMode('normal'); - setBulk(null); - setSel({}); - if (u.includes('list=')) { - setFetching(true); - const pl = await files.videoPlaylist(u).catch(() => null); - setFetching(false); - if (pl?.error || !pl?.urls?.length) return setInputError(pl?.error || 'No videos found in that playlist'); - setExpandedUrls(pl.urls); - setPhase('decide'); - } else { - setExpandedUrls([u]); - setPhase('preview'); - void fetchInline([u]); - } - }; - - // Interactive path: fetch each item's metadata sequentially, populating the cards. - const fetchInline = async (urls: string[]) => { - setFetching(true); - setMode('normal'); - setBulk(null); - setSel({}); - setEntries( - urls.map((v) => ({ - url: v, - status: 'loading', - hasVideo: true, - video: { phase: 'idle' }, - audio: { phase: 'idle' }, - })), - ); - 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, hasVideo: false }); - else - patch(i, { - status: 'ready', - title: info.title, - thumbnail: info.thumbnail, - duration: info.duration, - uploader: info.uploader, - hasVideo: (info.formats?.length ?? 1) > 0, - }); - } - setFetching(false); - }; - - // Job path: hand the whole playlist to the `download-media` script capability as a background job (it - // survives the panel closing). We pass the exact expanded video URLs — already individual, no `list=`, - // so the task won't re-expand and a Mix/radio playlist can't drift to a different set. cwd is - // home-relative (no leading slash) — the capability writes into it. - const startJob = async () => { - try { - const res = await client.post<{ jobId: string; status: string }>('/jobs', { - taskDirName: 'download-media', - inputs: { url: expandedUrls.join('\n'), format: jobFormat }, - cwd: targetDir().replace(/^\/+/, ''), - action: 'queue', - }); - lastDlDone.current = 0; - setJobId(res.jobId); - setJobStatus(res.status === 'pending' ? 'pending' : 'running'); - setJobProg(null); - setPhase('job'); - } catch { - setInputError('Could not start the job'); - } - }; - - // Poll the running job's progress; refresh the browser as each file lands. - useEffect(() => { - if (phase !== 'job' || !jobId) return; - let cancelled = false; - const poll = async () => { - const j = await client.get<{ status: string; progress: JobProgress | null }>(`/jobs/${jobId}`).catch(() => null); - if (cancelled || !j) return; - setJobStatus(j.status); - setJobProg(j.progress); - if (j.progress && j.progress.dl.done > lastDlDone.current) { - lastDlDone.current = j.progress.dl.done; - setRefreshSignal((n) => n + 1); - } - if (['completed', 'failed', 'stopped', 'interrupted'].includes(j.status)) clearInterval(timer); - }; - void poll(); - const timer = setInterval(poll, 2000); - return () => { - cancelled = true; - clearInterval(timer); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [phase, jobId]); - - const targetDir = () => { - const s = sanitizeFolder(subfolder); - if (!s) return basePath; - return basePath === '/' ? `/${s}` : `${basePath}/${s}`; - }; - - // 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(entriesRef.current[i]!.url, targetDir(), fmt === 'audio'); - const deadline = Date.now() + 60 * 60 * 1000; - for (;;) { - 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 patchFmt(i, fmt, { phase: 'error', error: st.error || 'Download failed' }); - if (st.status === 'transferring') patchFmt(i, fmt, { phase: 'saving' }); - if (st.status === 'done') { - patchFmt(i, fmt, { phase: 'done' }); - setRefreshSignal((n) => n + 1); - return; - } - } - } catch { - patchFmt(i, fmt, { phase: 'error', error: 'Could not start the download' }); - } - }; - - // 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)); - } - 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 ( -
-
- - Saving to {folderLabel} -
- - {phase === 'input' && ( -
{ - ev.preventDefault(); - void onFetch(); - }} - className="flex flex-col gap-3" - > - 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" - /> - {inputError &&

{inputError}

} - -
- )} - - {phase === 'decide' && ( -
- -

- Found {expandedUrls.length} items. -

- 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" - /> -
- {(['audio', 'video'] as Fmt[]).map((f) => ( - - ))} -
- - {inputError &&

{inputError}

} - -
- )} - - {phase === 'job' && ( - jobId && navigate(`/jobs/${jobId}`)} - onNew={() => { - setPhase('input'); - setUrl(''); - setJobId(null); - setJobProg(null); - }} - /> - )} - - {phase === 'preview' && ( -
-
- - {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" - /> - )} - - {/* Bulk action row (playlists only) — progress while running, otherwise the format actions. */} - {isPlaylist && - (bulk ? ( - - ) : mode === 'select' ? ( -
- - -
- ) : ( -
- {anyReadyVideo && ( - - )} - - -
- ))} - -
- {entries.length === 1 ? ( - void downloadFmt(0, fmt)} /> - ) : ( -
- {entries.map((e, i) => ( - toggleSel(i, fmt)} - onDownload={(fmt) => void downloadFmt(i, fmt)} - /> - ))} -
- )} -
-
- )} -
- ); -}; - -// ── Job progress view ── - -const JobView = ({ - prog, - status, - audio, - onOpen, - onNew, -}: { - prog: JobProgress | null; - status: string; - audio: boolean; - onOpen: () => void; - onNew: () => void; -}) => { - const terminal = ['completed', 'failed', 'stopped', 'interrupted'].includes(status); - const phaseLabel = - !prog || prog.phase === 'expanding' - ? 'Preparing…' - : prog.phase === 'metadata' - ? 'Fetching titles' - : prog.phase === 'download' - ? 'Downloading' - : 'Done'; - const Bar = ({ label, c, saved, failed }: { label: string; c: JobCounts; saved: string; failed: string }) => { - const pct = c.total ? ((c.done + c.failed) / c.total) * 100 : 0; - return ( -
-
- {label} - - {c.done + c.failed}/{c.total || '—'} - -
-
-
-
-
- - {c.done} {saved} - - {c.failed > 0 && ( - - {c.failed} {failed} - - )} -
-
- ); - }; - return ( -
-
- {terminal ? ( - status === 'completed' ? ( - - ) : ( - - ) - ) : ( - - )} - - {terminal ? status[0]!.toUpperCase() + status.slice(1) : phaseLabel} - - {audio ? 'Audio' : 'Video'} -
- - - {!terminal && prog?.phase === 'download' && prog.current && ( -

- {prog.current} -

- )} -
- - -
-

Runs on the server — you can close this panel; it keeps going.

-
- ); -}; - -// ── 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/useFileViewerPanels/layouts.ts b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/layouts.ts index 85527f2c..ed10067e 100644 --- a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/layouts.ts +++ b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/layouts.ts @@ -28,12 +28,6 @@ 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', diff --git a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx index 09cba6f0..d79b640c 100644 --- a/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx +++ b/src/workspaces/officerdev/src/hooks/useFileViewerPanels/useFileViewerPanels.tsx @@ -2,31 +2,11 @@ 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, - singleDownloadLayout, -} from './layouts'; +import { singleViewerLayout, singleCliampLayout, viewerWithEphemeralLayout, viewerWithEphemeralSplitLayout, singleChatLayout } 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', - 'download', - 'downloadRoot', -]; +const EPHEMERAL_KEYS = ['view', 'ephemeral', 'ephemeralRoot', 'ephemeral2', 'ephemeral2Root', 'ephemeral2Auto', 'chatContext', 'chatType', 'play']; export const useFileViewerPanels = (): EphemeralPanels | null => { const [searchParams, setSearchParams] = useSearchParams(); @@ -50,19 +30,16 @@ 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 = downloadPath - ? singleDownloadLayout - : playPath - ? singleCliampLayout - : chatContext - ? singleChatLayout - : viewPath && ephemeralPath && ephemeral2Path - ? viewerWithEphemeralSplitLayout - : viewPath && ephemeralPath - ? viewerWithEphemeralLayout - : singleViewerLayout; + const layout = playPath + ? singleCliampLayout + : chatContext + ? singleChatLayout + : viewPath && ephemeralPath && ephemeral2Path + ? viewerWithEphemeralSplitLayout + : viewPath && ephemeralPath + ? viewerWithEphemeralLayout + : singleViewerLayout; const onCloseViewer = useCallback(() => setSearchParams({}), [setSearchParams]); @@ -113,17 +90,6 @@ 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': { @@ -153,17 +119,12 @@ export const useFileViewerPanels = (): EphemeralPanels | null => { component: ChatEphemeralBody, onClose: onCloseChat, }, - 'files-download': { - header: VideoDownloadPanelHeader, - component: VideoDownloadPanel, - onClose: onCloseDownload, - }, }), - [onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat, onClosePlay, onCloseDownload], + [onCloseViewer, onCloseEphemeral, onCloseEphemeral2, onCloseChat, onClosePlay], ); - if (!viewPath && !chatContext && !playPath && !downloadPath) return null; - const onClose = downloadPath ? onCloseDownload : playPath ? onClosePlay : onCloseViewer; + if (!viewPath && !chatContext && !playPath) return null; + const onClose = playPath ? onClosePlay : onCloseViewer; return { layout, components, defaultBaseSize: 40, onClose }; };