diff --git a/apps/web/src/components/download-phase-metrics.tsx b/apps/web/src/components/download-phase-metrics.tsx deleted file mode 100644 index c68b415..0000000 --- a/apps/web/src/components/download-phase-metrics.tsx +++ /dev/null @@ -1,42 +0,0 @@ -type Props = { - tokenFetchMs: number | null; - ytdlpMs: number | null; - uploadMs: number | null; - totalMs: number | null; -}; - -type MetricRow = { - key: string; - label: string; - value: number; -}; - -function formatMs(value: number): string { - if (value >= 1000) return `${(value / 1000).toFixed(value >= 10_000 ? 1 : 2)}s`; - return `${Math.round(value)}ms`; -} - -function collectMetrics({ tokenFetchMs, ytdlpMs, uploadMs, totalMs }: Props): MetricRow[] { - const rows: MetricRow[] = []; - if (typeof tokenFetchMs === "number") - rows.push({ key: "token", label: "Token", value: tokenFetchMs }); - if (typeof ytdlpMs === "number") rows.push({ key: "fetch", label: "Fetch", value: ytdlpMs }); - if (typeof uploadMs === "number") rows.push({ key: "upload", label: "Upload", value: uploadMs }); - if (typeof totalMs === "number") rows.push({ key: "total", label: "Total", value: totalMs }); - return rows; -} - -export function DownloadPhaseMetrics({ tokenFetchMs, ytdlpMs, uploadMs, totalMs }: Props) { - const rows = collectMetrics({ tokenFetchMs, ytdlpMs, uploadMs, totalMs }); - if (rows.length === 0) return null; - return ( -
- {rows.map((row) => ( -
-

{row.label}

-

{formatMs(row.value)}

-
- ))} -
- ); -} diff --git a/apps/web/src/components/download-sheet.tsx b/apps/web/src/components/download-sheet.tsx index 6f3047f..45697c6 100644 --- a/apps/web/src/components/download-sheet.tsx +++ b/apps/web/src/components/download-sheet.tsx @@ -129,15 +129,12 @@ export function DownloadSheet({ stream, onClose, onDone }: Props) { )} diff --git a/apps/web/src/components/downloader-job-feedback.tsx b/apps/web/src/components/downloader-job-feedback.tsx index d4aa52c..c2825b1 100644 --- a/apps/web/src/components/downloader-job-feedback.tsx +++ b/apps/web/src/components/downloader-job-feedback.tsx @@ -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; }; @@ -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 && ( -
-
- Download in progress -
+
+
+
+

{label}

+

+ {message} +

+
+ {showPercent && ( + {Math.round(progress)}% + )} +
+ {showProgress && ( +
+
)} - {showWaiting && ( -
-
- -
- {stageLabel(stage)} - {normalizedProgress}% + {status === "running" && ( +
+ {DOWNLOADER_STEPS.map((step, index) => ( +
+ + {step}
-
+ ))}
)} - {resolvedLabel && !showWaiting && !visibleError && ( -

Selected: {resolvedLabel}

- )} - {visibleError && ( -

- {visibleError} -

- )} - {!visibleError && ( - + {resolvedLabel && !failed && ( +

Selected: {resolvedLabel}

)} - +
); } diff --git a/apps/web/src/hooks/use-downloader-job.ts b/apps/web/src/hooks/use-downloader-job.ts index 8663a26..6fae2bf 100644 --- a/apps/web/src/hooks/use-downloader-job.ts +++ b/apps/web/src/hooks/use-downloader-job.ts @@ -11,6 +11,7 @@ import type { DownloaderCreateJobRequest, DownloaderJobResponse, DownloaderJobStage, + DownloaderJobStatus, } from "../types/downloader"; const POLL_MS = 1_500; @@ -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 ?? ""), @@ -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; @@ -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); } @@ -114,6 +117,7 @@ export function useDownloaderJob() { openArtifact, reset, jobId, + status, stage, progressPercent, resolved, diff --git a/apps/web/src/lib/downloader-display.ts b/apps/web/src/lib/downloader-display.ts new file mode 100644 index 0000000..ca5d48a --- /dev/null +++ b/apps/web/src/lib/downloader-display.ts @@ -0,0 +1,89 @@ +import type { DownloaderJobStage, DownloaderJobStatus } from "../types/downloader"; + +export const DOWNLOADER_STEPS = ["Prepare", "Download", "Finalize"]; + +export function isCancelledDownloaderJob( + status: DownloaderJobStatus | null, + stage: DownloaderJobStage | null, + errorCode: string | null, +): boolean { + return (status === "failed" && errorCode === "cancelled") || stage === "cancelled"; +} + +export function isFailedDownloaderJob( + status: DownloaderJobStatus | null, + stage: DownloaderJobStage | null, + errorCode: string | null, +): boolean { + return ( + (status === "failed" || stage === "failed") && + !isCancelledDownloaderJob(status, stage, errorCode) + ); +} + +export function downloaderStageIndex( + status: DownloaderJobStatus | null, + stage: DownloaderJobStage | null, +): number { + if (status === "done" || stage === "done") return 3; + if (stage === "download" || stage === "downloading") return 1; + if (stage === "mux" || stage === "finalizing") return 2; + if (status === "running" || stage === "extract" || stage === "running") return 0; + return -1; +} + +export function downloaderStatusLabel( + status: DownloaderJobStatus | null, + stage: DownloaderJobStage | null, + errorCode: string | null, + forceWaiting: boolean, +): string { + if (forceWaiting) return "Opening file"; + if (isCancelledDownloaderJob(status, stage, errorCode)) return "Cancelled"; + if (isFailedDownloaderJob(status, stage, errorCode)) return "Failed"; + if (stage === "cached") return "Ready from cache"; + if (status === "done" || stage === "done") return "Ready"; + if (status === "queued" || stage === "queued") return "Queued"; + if (stage === "download" || stage === "downloading") return "Downloading"; + if (stage === "mux" || stage === "finalizing") return "Finalizing"; + return "Preparing download"; +} + +export function downloaderStatusMessage( + status: DownloaderJobStatus | null, + stage: DownloaderJobStage | null, + errorCode: string | null, + errorText: string | null, + forceWaiting: boolean, +): string { + if (forceWaiting) return "Handing the file to your browser."; + if (isCancelledDownloaderJob(status, stage, errorCode)) return "The download was cancelled."; + if (isFailedDownloaderJob(status, stage, errorCode)) + return errorText ?? "The download could not be completed."; + if (stage === "cached") return "Using the cached file."; + if (status === "done" || stage === "done") return "Your file is ready."; + if (status === "queued" || stage === "queued") return "Waiting for an available worker."; + if (stage === "download" || stage === "downloading") return "Downloading the selected media."; + if (stage === "mux" || stage === "finalizing") return "Combining the final file."; + return "Preparing streams and selecting the format."; +} + +export function downloaderProgressValue( + status: DownloaderJobStatus | null, + stage: DownloaderJobStage | null, + progressPercent: number | null, + forceWaiting: boolean, +): number { + if (forceWaiting || status === "done" || stage === "done") return 100; + if (typeof progressPercent === "number") return Math.min(100, Math.max(0, progressPercent)); + if (status === "running") return 12; + if (status === "queued") return 4; + return 0; +} + +export function shouldShowDownloaderProgress( + status: DownloaderJobStatus | null, + forceWaiting: boolean, +): boolean { + return forceWaiting || status === "queued" || status === "running" || status === "done"; +} diff --git a/apps/web/src/lib/downloader-events.ts b/apps/web/src/lib/downloader-events.ts index 1c17211..85c5b68 100644 --- a/apps/web/src/lib/downloader-events.ts +++ b/apps/web/src/lib/downloader-events.ts @@ -12,8 +12,11 @@ function toStringOrNull(value: unknown): string | null { function toStage(value: unknown): DownloaderJobResponse["stage"] { if (value === "queued") return "queued"; + if (value === "extract") return "extract"; if (value === "running") return "running"; + if (value === "download") return "download"; if (value === "downloading") return "downloading"; + if (value === "mux") return "mux"; if (value === "finalizing") return "finalizing"; if (value === "done") return "done"; if (value === "cached") return "cached"; @@ -94,6 +97,7 @@ export function subscribeDownloaderEvents( } }; eventSource.addEventListener("progress", onProgress); + eventSource.addEventListener("message", onProgress); eventSource.onerror = () => { if (closed) return; handlers.onError(); @@ -103,6 +107,7 @@ export function subscribeDownloaderEvents( return () => { closed = true; eventSource.removeEventListener("progress", onProgress); + eventSource.removeEventListener("message", onProgress); eventSource.close(); }; } diff --git a/apps/web/src/types/downloader.ts b/apps/web/src/types/downloader.ts index f24f2af..f774bd6 100644 --- a/apps/web/src/types/downloader.ts +++ b/apps/web/src/types/downloader.ts @@ -1,11 +1,14 @@ export type DownloaderMode = "video" | "audio"; -type DownloaderJobStatus = "queued" | "running" | "done" | "failed"; +export type DownloaderJobStatus = "queued" | "running" | "done" | "failed"; export type DownloaderJobStage = | "queued" + | "extract" | "running" + | "download" | "downloading" + | "mux" | "finalizing" | "done" | "cached"