import { useEffect, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { CalendarClock, Copy, Link2, Scissors } from "lucide-react"; import clsx from "clsx"; import { qk } from "../lib/queryKeys"; import { useLiveQuery } from "../lib/useLiveQuery"; import { api, type DownloadJob } from "../lib/api"; import { notify } from "../lib/notifications"; import { copyText } from "../lib/clipboard"; import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format"; // Download-row presentation + the small shared primitives (status pill, icon button, usage bar), // split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue, // library, shared, and admin tabs. const STATUS_CLS: Record = { queued: "text-muted", running: "text-accent", paused: "text-warn", done: "text-success", error: "text-danger", canceled: "text-muted", }; // Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse). // "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar. const DOWNLOAD_PHASES = ["video", "audio", "editing"]; const PHASE_KEYS = [ "video", "audio", "editing", "merging", "audio_extract", "thumbnail", "sponsorblock", "metadata", "processing", ]; export function StatusPill({ job }: { job: DownloadJob }) { const { t } = useTranslation(); // While downloading, show the concrete phase (video / audio / merging) so the user isn't // confused by the bar resetting between the separate video and audio streams. if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) { return ( {t(`downloads.phase.${job.phase}`)} ); } const key = job.expired ? "expired" : job.status; return ( {t(`downloads.status.${key}`)} ); } function Thumb({ job }: { job: DownloadJob }) { // Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so // a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields // rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for // the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed). const remote = job.asset?.thumbnail_url ?? null; // `job.poster_url` only SIGNALS a poster exists; the actual src must carry ?account= (see // api.posterImageUrl). A plain can't send the X-Siftlode-Account header, so without it the // poster resolves to the session-default account and 404s for a per-tab account's download — which, // now that the poster is the primary image, reads as a blank Library thumbnail. const poster = job.poster_url ? api.posterImageUrl(job.id) : null; const primary = poster || remote; // On load failure (a genuinely missing poster file) fall back to the remote source thumbnail. const [src, setSrc] = useState(primary); useEffect(() => setSrc(primary), [primary]); return (
{src ? ( { if (remote && src !== remote) setSrc(remote); }} /> ) : null}
); } export function jobTitle(job: DownloadJob): string { return job.display_name || job.asset?.title || job.source_ref; } // The channel to show under the title: a user override wins over the auto-extracted asset value. // `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text. function jobChannel(job: DownloadJob): { name: string; url: string | null } { return { name: job.display_uploader || job.asset?.uploader || job.source_ref, url: job.display_uploader_url || job.asset?.uploader_url || null, }; } export function IconBtn({ onClick, title, danger, children, }: { onClick: () => void; title: string; danger?: boolean; children: ReactNode; }) { return ( ); } // Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits. function displayUrl(url: string): string { try { const u = new URL(url); return (u.host + u.pathname + u.search).replace(/\/$/, ""); } catch { return url; } } // "Downloaded from" reference: opens the original page in a new tab, or copies the link. function SourceRef({ url }: { url: string }) { const { t } = useTranslation(); const copy = async () => { if (await copyText(url)) { notify({ level: "success", message: t("downloads.source.copied") }); } else { notify({ level: "error", message: t("downloads.source.copyFailed") }); } }; return (
{displayUrl(url)}
); } // Channel under the title — a clickable link when a channel URL is known, plain text otherwise. function ChannelLine({ job }: { job: DownloadJob }) { const { name, url } = jobChannel(job); if (url) { return (
{name}
); } return
{name}
; } export function DownloadRow({ job, onShowError, children, }: { job: DownloadJob; onShowError?: (job: DownloadJob) => void; children?: ReactNode; }) { const { t } = useTranslation(); const running = job.status === "running"; return (
{job.job_kind === "edit" && ( {t("downloads.clipBadge")} )} {jobTitle(job)}
{job.asset?.size_bytes ? ( · {formatBytes(job.asset.size_bytes)} ) : null} {job.asset?.duration_s ? ( · {formatDuration(job.asset.duration_s)} ) : null} {job.status === "done" && job.created_at ? ( · {relativeTime(job.created_at)} ) : null} {job.status === "error" ? ( ) : null}
{job.source_url && } {job.extra_links?.map((url) => ( ))} {running && (
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? ( // ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
) : ( <>
{job.progress}% {job.speed_bps ? {formatSpeed(job.speed_bps)} : null} {job.eta_s ? {formatEta(job.eta_s)} : null}
)}
)}
{children}
); } export function UsageBar() { const { t } = useTranslation(); // Poll live (like the library list) so the footprint updates the moment the worker finishes an // edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so // a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint // only moves while a job is in flight, so poll fast (2s) then and idle-back-off to 15s otherwise // (running_jobs counts queued+running — it flips back to 2s on the first poll after a new enqueue). const usage = useLiveQuery(qk.downloadUsage(), api.downloadUsage, { intervalMs: (u) => (u?.running_jobs ? 2000 : 15000), }); const u = usage.data; if (!u) return null; const pct = u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100); return (
{t("downloads.usage.title")} {formatBytes(u.footprint_bytes)} {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "} {u.unlimited ? t("downloads.usage.unlimited") : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
{!u.unlimited && (
90 ? "bg-danger" : "bg-accent")} style={{ width: `${pct}%` }} />
)}
); }