video download dialog keyed (57 -> 50)

URL/playlist fetch, per-item preview, and the download-all flow.

Most of what the scanner flagged here was the smaller half — the bulk was ternaries
and text following a `}` rather than a `>` (the same structural miss "Open" and
"Fetch" hit in FileItem), found by reading the file. dlLabel's three-way ternary,
Retry/Download, Audio only/Video, and the "· N items" count were all invisible to
the tool.

Distinguished two classes of error string carefully. `pl.error` and `info.error`
from a successful-but-failed server response are LEFT AS IS — server content,
same as apiError.message in phase 1's ServerError dialog. Our own fallback
defaults for a network failure the server never got to respond to ("Could not
fetch info", "Timed out", "Could not start the download") are ours and are
translated.

One real plural, itemCount, checked at 0/1/2 in both locales.
This commit is contained in:
2026-08-23 16:23:15 +00:00
parent 198c6c916b
commit 2e69b15de6
3 changed files with 86 additions and 21 deletions
@@ -4,6 +4,7 @@ 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';
import { useTranslation } from '../../../../locales';
type VideoDownloadDialogProps = {
fileBrowserManager: UseFileBrowserAppType;
@@ -37,6 +38,7 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const sanitizeFolder = (name: string) => name.replace(/[/\\]/g, '').replace(/^\.+/, '').trim();
export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogProps) => {
const { t } = useTranslation();
const { showVideoDownload, setShowVideoDownload, currentPath, refresh, files } = fileBrowserManager;
const [phase, setPhase] = useState<'input' | 'preview'>('input');
@@ -94,7 +96,9 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
// 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' }));
const info = await files
.videoInfo(urls[i]!)
.catch((): VideoInfo => ({ error: t('videoDownload.couldNotFetchInfo') }));
if (info.error) patch(i, { status: 'error', error: info.error });
else
patch(i, {
@@ -121,11 +125,12 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
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' });
if (Date.now() > deadline) return patch(i, { dl: 'error', dlError: t('videoDownload.timedOut') });
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 === 'error')
return patch(i, { dl: 'error', dlError: st.error || t('videoDownload.downloadFailed') });
if (st.status === 'transferring') patch(i, { dl: 'saving' });
if (st.status === 'done') {
patch(i, { dl: 'done', filename: st.filename });
@@ -134,7 +139,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
}
}
} catch {
patch(i, { dl: 'error', dlError: 'Could not start the download' });
patch(i, { dl: 'error', dlError: t('videoDownload.couldNotStart') });
}
};
@@ -152,17 +157,21 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
};
const dlLabel = (e: Entry) =>
e.dl === 'downloading' ? 'Downloading…' : e.dl === 'saving' ? 'Saving…' : e.dl === 'done' ? 'Saved' : '';
e.dl === 'downloading'
? t('videoDownload.downloading')
: e.dl === 'saving'
? t('videoDownload.saving')
: e.dl === 'done'
? t('videoDownload.saved')
: '';
return (
<Dialog open={showVideoDownload} onOpenChange={(open) => !open && handleClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Download video</DialogTitle>
<DialogTitle>{t('videoDownload.title')}</DialogTitle>
<DialogDescription>
{phase === 'input'
? 'Paste a video or playlist URL — it fetches details before downloading.'
: 'Review and download.'}
{phase === 'input' ? t('videoDownload.descriptionInput') : t('videoDownload.descriptionPreview')}
</DialogDescription>
</DialogHeader>
@@ -178,12 +187,12 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
autoFocus
value={url}
onChange={(ev) => setUrl(ev.target.value)}
placeholder="https://www.youtube.com/watch?v=… or …/playlist?list=…"
placeholder={t('videoDownload.urlPlaceholder')}
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)
{t('videoDownload.audioOnly')}
</label>
<div className="flex justify-end gap-2">
<button
@@ -191,7 +200,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
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
{t('newEntry.cancel')}
</button>
<button
type="submit"
@@ -199,7 +208,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
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
{t('videoDownload.fetch')}
</button>
</div>
</form>
@@ -211,11 +220,11 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
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
<ChevronLeft size={15} /> {t('videoDownload.back')}
</button>
<span className="text-xs text-duck-dark/50">
{audioOnly ? 'Audio only' : 'Video'}
{isPlaylist ? ` · ${entries.length} items` : ''}
{audioOnly ? t('videoDownload.audioOnlyLabel') : t('videoDownload.video')}
{isPlaylist ? ` · ${t('videoDownload.itemCount', { count: entries.length })}` : ''}
</span>
</div>
@@ -224,7 +233,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
<input
value={subfolder}
onChange={(ev) => setSubfolder(ev.target.value)}
placeholder="Subfolder (optional) — leave blank for this folder"
placeholder={t('videoDownload.subfolderPlaceholder')}
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
@@ -234,7 +243,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
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
{t('videoDownload.downloadAll')}
</button>
</div>
)}
@@ -265,7 +274,9 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
</div>
) : e.status === 'error' ? (
<>
<p className="truncate text-sm font-medium text-destructive">Could not fetch</p>
<p className="truncate text-sm font-medium text-destructive">
{t('videoDownload.couldNotFetch')}
</p>
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
</>
) : (
@@ -285,7 +296,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
<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
<Check size={14} /> {t('videoDownload.saved')}
</span>
) : e.dl === 'downloading' || e.dl === 'saving' ? (
<span className="flex items-center gap-1.5 text-xs text-duck-dark/60">
@@ -298,7 +309,7 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
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'}
{e.dl === 'error' ? t('videoDownload.retry') : t('videoDownload.download')}
</button>
)}
</div>
@@ -575,5 +575,32 @@
"placeholder": "https://github.com/owner/repo.git",
"submit": "Clone"
}
},
"videoDownload": {
"title": "Download video",
"descriptionInput": "Paste a video or playlist URL — it fetches details before downloading.",
"descriptionPreview": "Review and download.",
"urlPlaceholder": "https://www.youtube.com/watch?v=… or …/playlist?list=…",
"audioOnly": "Extract audio only (mp3)",
"fetch": "Fetch",
"back": "Back",
"audioOnlyLabel": "Audio only",
"video": "Video",
"itemCount": {
"one": "{{count}} item",
"other": "{{count}} items"
},
"subfolderPlaceholder": "Subfolder (optional) — leave blank for this folder",
"downloadAll": "Download all",
"couldNotFetch": "Could not fetch",
"downloading": "Downloading…",
"saving": "Saving…",
"saved": "Saved",
"retry": "Retry",
"download": "Download",
"couldNotFetchInfo": "Could not fetch info",
"timedOut": "Timed out",
"downloadFailed": "Download failed",
"couldNotStart": "Could not start the download"
}
}
@@ -575,5 +575,32 @@
"placeholder": "https://github.com/owner/repo.git",
"submit": "Clonar"
}
},
"videoDownload": {
"title": "Descarregar vídeo",
"descriptionInput": "Cole um URL de vídeo ou de lista de reprodução — os detalhes são obtidos antes de descarregar.",
"descriptionPreview": "Reveja e descarregue.",
"urlPlaceholder": "https://www.youtube.com/watch?v=… ou …/playlist?list=…",
"audioOnly": "Extrair apenas o áudio (mp3)",
"fetch": "Obter",
"back": "Voltar",
"audioOnlyLabel": "Apenas áudio",
"video": "Vídeo",
"itemCount": {
"one": "{{count}} item",
"other": "{{count}} itens"
},
"subfolderPlaceholder": "Subpasta (opcional) — deixe em branco para esta pasta",
"downloadAll": "Descarregar tudo",
"couldNotFetch": "Não foi possível obter",
"downloading": "A descarregar…",
"saving": "A guardar…",
"saved": "Guardado",
"retry": "Tentar novamente",
"download": "Descarregar",
"couldNotFetchInfo": "Não foi possível obter informação",
"timedOut": "Tempo esgotado",
"downloadFailed": "Falha ao descarregar",
"couldNotStart": "Não foi possível iniciar o descarregamento"
}
}