import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { CalendarClock, File, Film, Link2, Music, 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 { formatBytes, formatDateTime, formatDuration, formatEta, formatSpeed, relativeTime, } from "../lib/format"; import { navItemAttrs } from "../lib/listNav"; import { failCopyKeys } from "../lib/downloadFailure"; import { displayUrl, hasOwnTitle, jobTitle } from "./downloadTitle"; import { IconButton } from "./ui/IconButton"; import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; // 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}`)} ); } // The row answers to its OWN width (a container query), not the window's: the same list sits in a // phone, a split-screen tablet and a desktop column. Below 40rem the actions no longer leave the title // column room for the meta line, so the row stacks (E28 S3f3, the owner's pick A): the picture beside a // two-line title and the channel; the meta line across the whole row; then the source link and the // actions on one wrapping line. const THUMB_BOX = "w-28 @max-[40rem]/dlrow:w-24 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border"; 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. // Remembered BY the URL that failed, not as a value an effect re-syncs: these rows are recycled // down a long virtualized list, so a boolean-style mirror painted the previous job's fallback // over the next one's poster for a frame. See ui/Avatar, which had the same defect. // …and forgotten once `primary` has moved off it, for the same reason ui/Avatar does: a poster // that 404'd because its file was still being written should be re-requested when the row comes // back, not written off for the life of the recycled slot. Convergent, so it belongs in render. const [failedPrimary, setFailedPrimary] = useState(null); if (failedPrimary !== null && failedPrimary !== primary) setFailedPrimary(null); // The remote picture is the last resort, and it fails too (a dead CDN link): remembered the same // way, so the row falls to the glyph below instead of the browser's broken-image mark (E28 C5). const [failedRemote, setFailedRemote] = useState(null); if (failedRemote !== null && failedRemote !== remote) setFailedRemote(null); const candidate = failedPrimary && failedPrimary === primary ? remote : primary; const src = candidate && candidate === failedRemote ? null : candidate; // A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/ // archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail. if (job.asset?.media_kind === "file") { return (
); } return (
{src ? ( { if (src !== remote) setFailedPrimary(primary); else setFailedRemote(remote); }} /> ) : ( // No picture at all (a link that failed before its details, a poster that never came): a // quiet glyph of what it is, like the file glyph above — an empty box read as "still loading".
{job.asset?.media_kind === "audio" ? (
)}
); } // 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. No // channel known yet → no line: the ref it once fell back to repeated the title (the owner, 2026-09-27). function jobChannel(job: DownloadJob): { name: string | null; url: string | null } { return { name: job.display_uploader || job.asset?.uploader || null, url: job.display_uploader_url || job.asset?.uploader_url || null, }; } export function IconBtn({ onClick, title, danger, open, children, }: { onClick: () => void; title: string; danger?: boolean; /** The row's "open" action (play): Enter on a keyboard-walked row (lib/listNav) clicks it. */ open?: boolean; children: ReactNode; }) { return ( {children} ); } // A link inside the row keeps the BROWSER's own right-click menu (open in a new tab, copy the // address): the row's menu answers everywhere else on it (E28 C1). const nativeMenu = { onContextMenu: (e: React.MouseEvent) => e.stopPropagation() }; // "Downloaded from" reference: opens the original page in a new tab. Its copy moved into the row's // menu ("Copy link"); an extra link copies through the browser's own link menu. function SourceRef({ url }: { url: string }) { const { t } = useTranslation(); 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 (!name) return null; if (url) { return (
{name}
); } return
{name}
; } export function DownloadRow({ job, onShowError, navIndex, rowProps, more, children, }: { job: DownloadJob; onShowError?: (job: DownloadJob) => void; /** Its position in a keyboard-walkable list (lib/listNav). */ navIndex?: number; /** The list's menu triggers (right-click, long-press, Shift+F10): `useContextMenu().triggerProps`. */ rowProps?: Record; /** The row's "…", last in its action strip (E28 C1). */ more?: ReactNode; children?: ReactNode; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; const running = job.status === "running"; const linkTitle = !hasOwnTitle(job) && !!job.source_url; const sourceLink = linkTitle ? null : job.source_url; return (
{job.job_kind === "edit" && ( {t("downloads.clipBadge")} )} {linkTitle ? ( // No name yet: the title IS the link, so the link line below leaves it out (it said the // same URL a third time). {jobTitle(job)} ) : ( jobTitle(job) )}
{/* The meta line never wraps (the owner, 2026-09-27: on the iPad its "18.9 / MB" pieces broke into a jumble). One line, cut at its end, so the least telling part (the age) goes first. */}
{job.asset?.container ? ( // Concrete container (mp4 / m4a / webm …) — reads as the file format, and its audio-vs-video // extension is the video/audio distinction the running-only phase pill can't show at rest. · {job.asset.container} ) : null} {job.asset?.size_bytes ? ( · {formatBytes(job.asset.size_bytes, lang)} ) : null} {job.asset?.duration_s ? ( · {formatDuration(job.asset.duration_s)} ) : null} {job.status === "done" && job.created_at ? ( {" · "} {" "} {relativeTime(job.created_at, lang)} ) : null} {job.status === "error" ? ( <> {" "} onShowError?.(job)} className="text-danger inline-block leading-6 min-h-6 touch-target pointer-coarse:leading-(--touch-target)" title={t("downloads.errors.fail.title")} > · {t(failCopyKeys(job, "short"))}{" "} {t("downloads.errors.fail.detailsLink")} ) : null}
{/* Wide, this wrapper is `contents`: the source and the actions are grid items of their own (src under the meta, act down the right). Stacked, it is ONE wrapping line: the link and the actions side by side while they fit, the actions under the link only when they don't (at 375). On touch each is a 44px box, so separate lines stacked 44px gaps (the owner, 2026-09-27: "what are those huge empty parts?"). */}
{(!!sourceLink || !!job.extra_links?.length) && (
{sourceLink && } {job.extra_links?.map((url) => ( ))}
)} {/* The row's visible actions — its primary one and the save link — then its "…": the md rung here (the row stands alone), the sm rung in the modals, one IconBtn for both. */}
{children} {more}
{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, lang)} : null} {job.eta_s ? {formatEta(t, job.eta_s)} : null}
)}
)}
); } export function UsageBar() { const { t, i18n } = useTranslation(); const lang = i18n.language; // 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, lang)} {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`} ·{" "} {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}%` }} />
)}
); }