Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 0 additions & 42 deletions apps/web/src/components/download-phase-metrics.tsx

This file was deleted.

5 changes: 1 addition & 4 deletions apps/web/src/components/download-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,12 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) {
</div>
)}
<DownloaderJobFeedback
status={downloader.status}
stage={downloader.stage}
progressPercent={downloader.progressPercent}
resolved={downloader.resolved}
errorCode={downloader.errorCode}
errorText={artifactError ?? errorText}
tokenFetchMs={downloader.tokenFetchMs}
ytdlpMs={downloader.ytdlpMs}
uploadMs={downloader.uploadMs}
totalMs={downloader.totalMs}
immersive={showWorkingState}
forceWaiting={completion.isCompleting}
/>
Expand Down
135 changes: 61 additions & 74 deletions apps/web/src/components/downloader-job-feedback.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import type { DownloaderJobStage, DownloaderResolvedSelection } from "../types/downloader";
import { CardLiquidFill } from "./card-liquid-fill";
import { DownloadPhaseMetrics } from "./download-phase-metrics";
import {
DOWNLOADER_STEPS,
downloaderProgressValue,
downloaderStageIndex,
downloaderStatusLabel,
downloaderStatusMessage,
isCancelledDownloaderJob,
isFailedDownloaderJob,
shouldShowDownloaderProgress,
} from "../lib/downloader-display";
import type {
DownloaderJobStage,
DownloaderJobStatus,
DownloaderResolvedSelection,
} from "../types/downloader";

type Props = {
status: DownloaderJobStatus | null;
stage: DownloaderJobStage | null;
progressPercent: number | null;
resolved: DownloaderResolvedSelection | null;
errorCode: string | null;
errorText: string | null;
tokenFetchMs: number | null;
ytdlpMs: number | null;
uploadMs: number | null;
totalMs: number | null;
immersive?: boolean;
forceWaiting?: boolean;
};
Expand Down Expand Up @@ -39,94 +48,72 @@ function exactUnavailableMessage(resolved: DownloaderResolvedSelection | null):
return "Selected format is unavailable. Pick another format.";
}

function stageLabel(stage: DownloaderJobStage | null): string {
if (stage === "queued") return "Queued";
if (stage === "running" || stage === "downloading") return "Downloading";
if (stage === "finalizing") return "Finalizing";
if (stage === "cached") return "Ready from cache";
if (stage === "done") return "Done";
if (stage === "cancelled") return "Cancelled";
if (stage === "failed") return "Failed";
return "Preparing";
}

export function DownloaderJobFeedback({
status,
stage,
progressPercent,
resolved,
errorCode,
errorText,
tokenFetchMs,
ytdlpMs,
uploadMs,
totalMs,
immersive = false,
forceWaiting = false,
}: Props) {
const normalizedProgress =
typeof progressPercent === "number" ? Math.min(100, Math.max(0, progressPercent)) : 0;
const hasProgress = typeof progressPercent === "number";
const resolvedLabel = formatResolved(resolved);
const visibleError =
errorCode === "exact_selection_unavailable"
? exactUnavailableMessage(resolved)
: (errorText ?? null);
const showWaiting =
(forceWaiting ||
stage === "queued" ||
stage === "running" ||
stage === "downloading" ||
stage === "finalizing") &&
hasProgress &&
!visibleError;
const cancelled = isCancelledDownloaderJob(status, stage, errorCode);
const failed = isFailedDownloaderJob(status, stage, errorCode);
const label = downloaderStatusLabel(status, stage, errorCode, forceWaiting);
const message = downloaderStatusMessage(status, stage, errorCode, visibleError, forceWaiting);
const progress = downloaderProgressValue(status, stage, progressPercent, forceWaiting);
const activeStep = downloaderStageIndex(status, stage);
const showProgress = shouldShowDownloaderProgress(status, forceWaiting) && !failed && !cancelled;
const showPercent = typeof progressPercent === "number" && status === "running";

if (!status && !forceWaiting && !resolvedLabel && !visibleError) return null;

return (
<>
{showWaiting && (
<div
className={
immersive
? "h-[min(52svh,24rem)] min-h-52 overflow-hidden rounded-xl border border-border bg-app/80 p-1.5"
: "mt-2 rounded-md border border-border bg-surface/60 p-1.5"
}
>
<div className="h-full overflow-hidden rounded-lg border border-border bg-app/70">
<img
src="/downloader-waiting.gif"
alt="Download in progress"
className="h-full w-full object-contain"
loading="lazy"
/>
</div>
<section
className={`mt-3 rounded-2xl border p-3 ${immersive ? "bg-surface/80" : "bg-surface/60"} ${failed ? "border-danger/50" : "border-border-strong"}`}
role={failed ? "alert" : "status"}
aria-live="polite"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-fg">{label}</p>
<p className={`mt-1 text-xs ${failed ? "text-danger-strong" : "text-fg-muted"}`}>
{message}
</p>
</div>
{showPercent && (
<span className="text-sm font-semibold text-fg">{Math.round(progress)}%</span>
)}
</div>
{showProgress && (
<div className="mt-3 h-2 overflow-hidden rounded-full bg-app">
<div
className="h-full rounded-full bg-fg transition-[width] duration-500 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
)}
{showWaiting && (
<div className="mt-2 rounded-lg border border-border bg-surface/70 p-2">
<div className="relative h-11 overflow-hidden rounded-md border border-border-strong bg-app/80">
<CardLiquidFill progress={normalizedProgress} />
<div className="absolute inset-0 flex items-center justify-between px-2 text-xs">
<span className="text-fg">{stageLabel(stage)}</span>
<span className="font-medium text-fg">{normalizedProgress}%</span>
{status === "running" && (
<div className="mt-3 grid grid-cols-3 gap-2">
{DOWNLOADER_STEPS.map((step, index) => (
<div key={step} className="flex items-center gap-2 text-[11px] text-fg-muted">
<span
className={`h-2 w-2 rounded-full ${index <= activeStep ? "bg-fg" : "bg-surface-soft"}`}
/>
<span>{step}</span>
</div>
</div>
))}
</div>
)}
{resolvedLabel && !showWaiting && !visibleError && (
<p className="mt-2 text-xs text-fg-muted">Selected: {resolvedLabel}</p>
)}
{visibleError && (
<p className="mt-2 text-xs text-danger-strong" role="alert">
{visibleError}
</p>
)}
{!visibleError && (
<DownloadPhaseMetrics
tokenFetchMs={tokenFetchMs}
ytdlpMs={ytdlpMs}
uploadMs={uploadMs}
totalMs={totalMs}
/>
{resolvedLabel && !failed && (
<p className="mt-3 truncate text-xs text-fg-soft">Selected: {resolvedLabel}</p>
)}
</>
</section>
);
}
48 changes: 26 additions & 22 deletions apps/web/src/hooks/use-downloader-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
DownloaderCreateJobRequest,
DownloaderJobResponse,
DownloaderJobStage,
DownloaderJobStatus,
} from "../types/downloader";

const POLL_MS = 1_500;
Expand All @@ -34,7 +35,7 @@ export function useDownloaderJob() {
});
}, [jobId]);

const status = useQuery({
const query = useQuery({
queryKey: ["downloader-job", jobId],
enabled: typeof jobId === "string" && jobId.length > 0,
queryFn: () => fetchDownloaderJob(jobId ?? ""),
Expand All @@ -51,34 +52,35 @@ export function useDownloaderJob() {
},
});
const job = useMemo(() => {
if (!eventJob) return status.data;
if (!status.data || status.data.id !== eventJob.id) return eventJob;
if (status.data.status === "done" || status.data.status === "failed") {
if (!eventJob) return query.data;
if (!query.data || query.data.id !== eventJob.id) return eventJob;
if (query.data.status === "done" || query.data.status === "failed") {
return {
...eventJob,
...status.data,
resolved: status.data.resolved ?? eventJob.resolved,
error: status.data.error ?? eventJob.error,
errorCode: status.data.errorCode ?? eventJob.errorCode,
tokenFetchMs: status.data.tokenFetchMs ?? eventJob.tokenFetchMs,
ytdlpMs: status.data.ytdlpMs ?? eventJob.ytdlpMs,
uploadMs: status.data.uploadMs ?? eventJob.uploadMs,
totalMs: status.data.totalMs ?? eventJob.totalMs,
...query.data,
resolved: query.data.resolved ?? eventJob.resolved,
error: query.data.error ?? eventJob.error,
errorCode: query.data.errorCode ?? eventJob.errorCode,
tokenFetchMs: query.data.tokenFetchMs ?? eventJob.tokenFetchMs,
ytdlpMs: query.data.ytdlpMs ?? eventJob.ytdlpMs,
uploadMs: query.data.uploadMs ?? eventJob.uploadMs,
totalMs: query.data.totalMs ?? eventJob.totalMs,
};
}
return {
...status.data,
...query.data,
...eventJob,
resolved: eventJob.resolved ?? status.data.resolved,
error: eventJob.error ?? status.data.error,
errorCode: eventJob.errorCode ?? status.data.errorCode,
resolved: eventJob.resolved ?? query.data.resolved,
error: eventJob.error ?? query.data.error,
errorCode: eventJob.errorCode ?? query.data.errorCode,
};
}, [eventJob, status.data]);
}, [eventJob, query.data]);

const isQueued = create.isPending || job?.status === "queued";
const isRunning = job?.status === "running";
const isDone = job?.status === "done";
const isFailed = job?.status === "failed";
const status: DownloaderJobStatus | null = create.isPending ? "queued" : (job?.status ?? null);
const isQueued = status === "queued";
const isRunning = status === "running";
const isDone = status === "done";
const isFailed = status === "failed";
const stage: DownloaderJobStage | null = job?.stage ?? null;
const progressPercent = typeof job?.progressPercent === "number" ? job.progressPercent : null;
const resolved = job?.resolved ?? null;
Expand All @@ -90,11 +92,12 @@ export function useDownloaderJob() {
const errorText =
create.error instanceof Error
? create.error.message
: job?.error || (status.error instanceof Error ? status.error.message : null);
: job?.error || (query.error instanceof Error ? query.error.message : null);

function start(payload: DownloaderCreateJobRequest) {
setEventJob(null);
setSseUnavailable(false);
create.reset();
create.mutate(payload);
}

Expand All @@ -114,6 +117,7 @@ export function useDownloaderJob() {
openArtifact,
reset,
jobId,
status,
stage,
progressPercent,
resolved,
Expand Down
Loading