import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { File, Film, Music } from "lucide-react"; import clsx from "clsx"; import { DOWNLOAD_PHASES } from "../../lib/downloadQueue"; import { api, type DownloadJob } from "../../lib/api"; import { IconButton } from "../../components/ui/IconButton"; import { MediaProgress } from "../../components/ui/MediaCard"; import RemoteImage from "../../components/ui/RemoteImage"; // The small shared primitives of a download (status pill, icon button, the job's picture), all purely // prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the // details dialog the icon button. const STATUS_CLS: Record = { queued: "text-muted", running: "text-accent", paused: "text-warn", done: "text-success", error: "text-danger", canceled: "text-muted", }; 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 && DOWNLOAD_PHASES.includes(job.phase)) { return ( {t(`downloads.phase.${job.phase}`)} ); } return ( {t(`downloads.status.${job.status}`)} ); } // A picture's box when the caller gives none: a thumbnail-sized 16:9 frame. const THUMB_BOX = "w-28 aspect-video shrink-0 rounded-lg overflow-hidden bg-surface border border-border"; /** A job's picture: its poster, else the source's thumbnail, else a glyph of what it is. `box` sizes * it (the Files list draws a smaller one). */ // An image larger than this keeps the file glyph: a row's thumbnail is the whole file, and a page of // camera originals would pull hundreds of megabytes for pictures a few rem wide. const IMAGE_THUMB_MAX_BYTES = 8 * 1024 * 1024; /** An image file's own picture for its thumbnail, or null (not an image, not readable, too big). */ function imageThumbOf(job: DownloadJob): string | null { const a = job.asset; if (a?.preview !== "image" || !job.can_download) return null; if ((a.size_bytes ?? Infinity) > IMAGE_THUMB_MAX_BYTES) return null; return api.previewUrl(job.id); } export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: string }) { // 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 src is api.posterImageUrl. const poster = job.poster_url ? api.posterImageUrl(job.id) : null; // A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/ // archive, not playable media. An image is its own picture (its sandboxed preview); the rest show // a neutral file glyph instead of an (always-empty) thumbnail. A preview that fails is an answer // (gone since, or not an image after all), not a hiccup: the glyph at once, no retries. if (job.asset?.media_kind === "file") { const glyph =