"use client"; import { formatDuration } from "yt-dlp-transcript-common/lib/format"; import { useNow } from "../../lib/usePolledPayload"; import type { JobProgressMetric, JobTaskKind, } from "yt-dlp-transcript-common/jobs/registry"; import type { JobRowTask, JobRowView } from "yt-dlp-transcript-common/views/jobRowView"; // The progress bars, drawn by every job row (./JobRow.tsx): the /jobs table, // the card list, and — `compact` — the monitor widget and the in-flight lists. // The task bar's aria-label and its `gap-0.5` wrapper are what the suite climbs // from to read the elapsed timer, so they live in ONE place rather than being // restated by whichever surface draws them. // // COMPACT KEEPS THE WIDGET'S GLYPHS. A pinned widget reads "↓ 5/10", "✎ id"; // the full form reads "Downloads: 5 / 10", "Transcribing id", and the suite // asserts the full form's names (`/Transcribing slowop/`), so the two label // sets are both kept, each on its own variant. // Records over JobTaskKind, so adding a kind is a compile error here. const TASK_KIND_VERB: Record = { download: "Downloading", transcribe: "Transcribing", digest: "Digesting", backfill: "Backfilling", relocate: "Moving media", }; const METRIC_FILL_BY_TASK: Record = { download: "bg-success/60", transcribe: "bg-success", digest: "bg-info", backfill: "bg-warning", relocate: "bg-info/70", }; // The compact bar's glyph per task kind, and the probing glyph. const TASK_KIND_GLYPH: Record = { download: "\u2193", transcribe: "\u270e", digest: "\u00b6", backfill: "\u21ba", // A right arrow: a relocate task is a channel's media moving to another disk. relocate: "\u21e2", }; export function TaskProgressBar({ task, compact = false, }: { task: JobRowTask; compact?: boolean; }) { // How long this task has been running. Null until mounted (see useNow); // formatDuration returns "" for 0, so the just-started case shows "0:00". const now = useNow(); const probing = task.phase === "probing"; const verb = compact ? probing ? "🔍" : TASK_KIND_GLYPH[task.kind] : probing ? "Probing audio" : TASK_KIND_VERB[task.kind]; // While probing, fill against the estimated probe duration (a distinct violet // "scanning" bar) rather than the frozen download fraction. yt-dlp is paused, // so the download fraction wouldn't advance anyway. Falls back to an // indeterminate violet pulse on the first probe (no duration estimate yet). const probeFraction = probing && now !== null && typeof task.probeStartedAt === "number" && typeof task.probeEtaSeconds === "number" && task.probeEtaSeconds > 0 ? Math.max( 0, Math.min(1, (now - task.probeStartedAt) / (task.probeEtaSeconds * 1000)), ) : undefined; const hasFraction = probing ? typeof probeFraction === "number" : typeof task.fraction === "number"; const fraction = probing ? probeFraction : task.fraction; const pct = hasFraction ? Math.round((fraction as number) * 100) : 0; const fillClass = probing ? "bg-violet-400 dark:bg-violet-500" : METRIC_FILL_BY_TASK[task.kind]; const pulseClass = probing ? "bg-violet-400 dark:bg-violet-500" : "bg-warning"; const elapsed = now === null ? null : formatDuration(Math.max(0, Math.round((now - task.startedAt) / 1000))) || "0:00"; const meta = [ elapsed, hasFraction ? `${pct}%` : null, probing ? (compact ? "probing" : "probing audio…") : task.detail, ] .filter(Boolean) .join(" · "); return (
{verb} {task.label} {!compact && task.workerName && ( {" "} on {task.workerName} )} {meta}
{hasFraction ? (
) : ( // Indeterminate: no parseable progress yet (e.g. before whisper's // header line or yt-dlp's first percent, or the first audio probe). // A full-width pulse reads as "working, no progress number" — distinct // from the fill color so it can't be mistaken for a stalled ~33%.
)}
); } // One place per metric, so adding a metric to JobProgressMetric is a compile // error here (Record over the union) rather than a silently-wrong label. const METRIC_LABELS: Record = { downloads: "Downloads", transcripts: "Transcripts", digests: "Digests", backfills: "Backfill", scans: "Metadata scan", clips: "Clip windows", }; // Per-metric glyph for the compact line ("↓ 5/10"). const METRIC_PREFIX: Record = { downloads: "\u2193 ", transcripts: "", digests: "\u00b6 ", backfills: "\u21ba ", scans: "\u2315 ", clips: "\u2702 ", }; const METRIC_FILL: Record = { downloads: "bg-success/60", transcripts: "bg-success", digests: "bg-info", backfills: "bg-warning", scans: "bg-info/60", clips: "bg-success/60", }; // One-line textual summary of a job's batch progress, e.g. "↓ 5/10 · ~2m left" // — the compact heading (headingProgress) and the compact bar's caption. export function compactProgressText( progress: NonNullable, showEta: boolean, ): string { const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`; const etaText = compactEta(progress, showEta); return etaText ? `${label} · ${etaText}` : label; } function compactEta( progress: NonNullable, showEta: boolean, ): string | null { const remaining = progress.target - progress.current; return !showEta || remaining <= 0 ? null : typeof progress.etaSeconds === "number" ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left` : "…"; } export function JobProgressBar({ progress, compact = false, showEta = true, }: { progress: NonNullable; compact?: boolean; // Compact only: the widget's `eta` switch. showEta?: boolean; }) { if (compact) { const label = `${METRIC_PREFIX[progress.metric]}${progress.current}/${progress.target}`; const etaText = compactEta(progress, showEta); return (
{label} {etaText && {etaText}}
); } const label = `${METRIC_LABELS[progress.metric]}: ${progress.current} / ${progress.target}`; // Append an ETA once the batch has a measured average. formatDuration returns // "" for 0/falsy, so guard against printing a bare "·". const remaining = progress.target - progress.current; const etaText = remaining <= 0 ? null : typeof progress.etaSeconds === "number" ? `~${formatDuration(Math.max(1, Math.round(progress.etaSeconds)))} left` : "estimating…"; const fillClass = METRIC_FILL[progress.metric]; return (
{label} {etaText ? {etaText} : null}
); }