@maxedgar/yeet 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +527 -160
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -8,9 +8,9 @@ import { render } from "ink";
8
8
  import { useCallback, useEffect as useEffect4, useRef as useRef3, useState as useState4 } from "react";
9
9
  import os3 from "os";
10
10
  import path3 from "path";
11
- import { Box as Box5, Text as Text7, useApp, useInput as useInput2, useStdout as useStdout3 } from "ink";
11
+ import { Box as Box6, Text as Text8, useApp, useInput as useInput2, useStdout as useStdout3 } from "ink";
12
12
  import SelectInput from "ink-select-input";
13
- import Spinner from "ink-spinner";
13
+ import Spinner2 from "ink-spinner";
14
14
 
15
15
  // src/components/framed-input.tsx
16
16
  import { Box, Text } from "ink";
@@ -283,20 +283,117 @@ function ProgressBar({ percent, width = 30 }) {
283
283
  ] });
284
284
  }
285
285
 
286
+ // src/components/queue-list.tsx
287
+ import { Box as Box5, Text as Text5 } from "ink";
288
+ import Spinner from "ink-spinner";
289
+
290
+ // src/lib/format.ts
291
+ function formatBytes(bytes) {
292
+ if (!Number.isFinite(bytes) || bytes <= 0) return "";
293
+ const units = ["B", "KB", "MB", "GB"];
294
+ let value = bytes;
295
+ let unit = 0;
296
+ while (value >= 1024 && unit < units.length - 1) {
297
+ value /= 1024;
298
+ unit++;
299
+ }
300
+ return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
301
+ }
302
+ function formatDuration(seconds) {
303
+ if (!Number.isFinite(seconds) || seconds <= 0) return "";
304
+ const s = Math.round(seconds);
305
+ const h = Math.floor(s / 3600);
306
+ const m = Math.floor(s % 3600 / 60);
307
+ const sec = s % 60;
308
+ const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
309
+ const ss = String(sec).padStart(2, "0");
310
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
311
+ }
312
+ function truncate(text, max) {
313
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
314
+ }
315
+ function shortenPath(filepath, homedir, max = 60) {
316
+ const pretty = filepath.startsWith(homedir) ? `~${filepath.slice(homedir.length)}` : filepath;
317
+ if (pretty.length <= max) return pretty;
318
+ const ext = /\.\w{1,5}$/.exec(pretty)?.[0] ?? "";
319
+ return `${pretty.slice(0, max - ext.length - 1)}\u2026${ext}`;
320
+ }
321
+ function wrapText(text, width) {
322
+ const lines = [];
323
+ let line = "";
324
+ for (const word of text.split(/\s+/).filter(Boolean)) {
325
+ if (!line) line = word;
326
+ else if (line.length + 1 + word.length <= width) line += ` ${word}`;
327
+ else {
328
+ lines.push(line);
329
+ line = word;
330
+ }
331
+ }
332
+ if (line) lines.push(line);
333
+ return lines;
334
+ }
335
+ function formatSpeed(bytesPerSecond) {
336
+ if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "";
337
+ return `${formatBytes(bytesPerSecond)}/s`;
338
+ }
339
+ function formatEta(seconds) {
340
+ if (!Number.isFinite(seconds) || seconds <= 0) return "";
341
+ return formatDuration(seconds);
342
+ }
343
+
344
+ // src/components/queue-list.tsx
345
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
346
+ var MAX_VISIBLE = 7;
347
+ function StatusIcon({ status }) {
348
+ const theme = useTheme();
349
+ if (status === "active") {
350
+ return /* @__PURE__ */ jsx6(Text5, { color: theme.primary, children: /* @__PURE__ */ jsx6(Spinner, { type: "dots" }) });
351
+ }
352
+ if (status === "done") return /* @__PURE__ */ jsx6(Text5, { color: theme.primary, children: "\u2713" });
353
+ if (status === "error") return /* @__PURE__ */ jsx6(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u2717" });
354
+ if (status === "skipped") return /* @__PURE__ */ jsx6(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u292B" });
355
+ return /* @__PURE__ */ jsx6(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: "\xB7" });
356
+ }
357
+ function QueueList({ items, width }) {
358
+ const theme = useTheme();
359
+ if (items.length === 0) return null;
360
+ const activeIndex = items.findIndex((item) => item.status === "active");
361
+ const anchor = activeIndex === -1 ? items.length - 1 : activeIndex;
362
+ const start = Math.max(0, Math.min(anchor - Math.floor(MAX_VISIBLE / 2), Math.max(0, items.length - MAX_VISIBLE)));
363
+ const visible = items.slice(start, start + MAX_VISIBLE);
364
+ const hiddenAbove = start;
365
+ const hiddenBelow = Math.max(0, items.length - (start + visible.length));
366
+ return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", width, children: [
367
+ hiddenAbove > 0 ? /* @__PURE__ */ jsx6(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: ` \u2191 ${hiddenAbove} more` }) : null,
368
+ visible.map((item) => /* @__PURE__ */ jsxs4(Box5, { children: [
369
+ /* @__PURE__ */ jsx6(Box5, { width: 2, children: /* @__PURE__ */ jsx6(StatusIcon, { status: item.status }) }),
370
+ /* @__PURE__ */ jsx6(
371
+ Text5,
372
+ {
373
+ color: item.status === "done" || item.status === "active" ? theme.primary : theme.gray,
374
+ dimColor: item.status !== "done" && item.status !== "active" && theme.dimSecondary,
375
+ children: truncate(item.title || item.url, Math.max(4, width - 3))
376
+ }
377
+ )
378
+ ] }, item.id)),
379
+ hiddenBelow > 0 ? /* @__PURE__ */ jsx6(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: ` \u2193 ${hiddenBelow} more` }) : null
380
+ ] });
381
+ }
382
+
286
383
  // src/components/shortcuts.tsx
287
- import { Text as Text5 } from "ink";
288
- import { Fragment, jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
384
+ import { Text as Text6 } from "ink";
385
+ import { Fragment, jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
289
386
  function Shortcuts({ items, leading }) {
290
387
  const theme = useTheme();
291
- return /* @__PURE__ */ jsxs4(Text5, { children: [
292
- leading ? /* @__PURE__ */ jsxs4(Fragment, { children: [
388
+ return /* @__PURE__ */ jsxs5(Text6, { children: [
389
+ leading ? /* @__PURE__ */ jsxs5(Fragment, { children: [
293
390
  leading,
294
- /* @__PURE__ */ jsx6(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: " \xB7 " })
391
+ /* @__PURE__ */ jsx7(Text6, { color: theme.gray, dimColor: theme.dimSecondary, children: " \xB7 " })
295
392
  ] }) : null,
296
- items.map(([key, label], index) => /* @__PURE__ */ jsxs4(Text5, { children: [
297
- index > 0 ? /* @__PURE__ */ jsx6(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: " \xB7 " }) : null,
298
- /* @__PURE__ */ jsx6(Text5, { color: theme.primary, children: key }),
299
- /* @__PURE__ */ jsxs4(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: [
393
+ items.map(([key, label], index) => /* @__PURE__ */ jsxs5(Text6, { children: [
394
+ index > 0 ? /* @__PURE__ */ jsx7(Text6, { color: theme.gray, dimColor: theme.dimSecondary, children: " \xB7 " }) : null,
395
+ /* @__PURE__ */ jsx7(Text6, { color: theme.primary, children: key }),
396
+ /* @__PURE__ */ jsxs5(Text6, { color: theme.gray, dimColor: theme.dimSecondary, children: [
300
397
  " ",
301
398
  label
302
399
  ] })
@@ -306,7 +403,7 @@ function Shortcuts({ items, leading }) {
306
403
 
307
404
  // src/components/text-input.tsx
308
405
  import { useRef as useRef2, useState as useState3 } from "react";
309
- import { Text as Text6, useInput } from "ink";
406
+ import { Text as Text7, useInput } from "ink";
310
407
 
311
408
  // src/lib/use-mouse-click.ts
312
409
  import { useEffect as useEffect3, useRef } from "react";
@@ -338,7 +435,7 @@ function useMouseClick(onClick, isActive) {
338
435
  var stripMouseReports = (value) => value.replace(/\u001B?\[?<\d+;\d+;\d+[Mm]/g, "");
339
436
 
340
437
  // src/components/text-input.tsx
341
- import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
438
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
342
439
  var wordLeft = (text, from) => {
343
440
  let i = from;
344
441
  while (i > 0 && !/\w/.test(text[i - 1])) i--;
@@ -466,18 +563,35 @@ function TextInput({
466
563
  if (cursor > offset + span - 1) offset = cursor - span + 1;
467
564
  offsetRef.current = offset;
468
565
  if (!value) {
469
- return /* @__PURE__ */ jsxs5(Text6, { children: [
470
- /* @__PURE__ */ jsx7(Text6, { inverse: true, children: " " }),
471
- /* @__PURE__ */ jsx7(Text6, { color: theme.gray, dimColor: theme.dimSecondary, children: placeholder.slice(0, span - 1) })
566
+ return /* @__PURE__ */ jsxs6(Text7, { children: [
567
+ /* @__PURE__ */ jsx8(Text7, { inverse: true, children: " " }),
568
+ /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: placeholder.slice(0, span - 1) })
472
569
  ] });
473
570
  }
474
571
  const cells = Array.from({ length: Math.min(span, value.length - offset + 1) }, (_, column) => {
475
572
  const index = offset + column;
476
573
  const selected = selection !== null && index >= selection[0] && index < selection[1];
477
574
  const atCursor = selection === null && index === cursor;
478
- return /* @__PURE__ */ jsx7(Text6, { color: theme.primary, inverse: selected || atCursor, children: value[index] ?? " " }, index);
575
+ return /* @__PURE__ */ jsx8(Text7, { color: theme.primary, inverse: selected || atCursor, children: value[index] ?? " " }, index);
479
576
  });
480
- return /* @__PURE__ */ jsx7(Text6, { children: cells });
577
+ return /* @__PURE__ */ jsx8(Text7, { children: cells });
578
+ }
579
+
580
+ // src/lib/batch.ts
581
+ function makeBatchItems(entries) {
582
+ return entries.map((entry, index) => ({
583
+ id: `${index}-${entry.url}`,
584
+ url: entry.url,
585
+ title: entry.title,
586
+ status: "pending"
587
+ }));
588
+ }
589
+ function batchSummary(items) {
590
+ const done = items.filter((item) => item.status === "done").length;
591
+ const error = items.filter((item) => item.status === "error").length;
592
+ const skipped = items.filter((item) => item.status === "skipped").length;
593
+ const pending = items.filter((item) => item.status === "pending" || item.status === "active").length;
594
+ return { done, error, skipped, pending, total: items.length };
481
595
  }
482
596
 
483
597
  // src/lib/click-map.ts
@@ -531,60 +645,6 @@ function frameRowSpan(row) {
531
645
  return [first + 1, line.trimEnd().length];
532
646
  }
533
647
 
534
- // src/lib/format.ts
535
- function formatBytes(bytes) {
536
- if (!Number.isFinite(bytes) || bytes <= 0) return "";
537
- const units = ["B", "KB", "MB", "GB"];
538
- let value = bytes;
539
- let unit = 0;
540
- while (value >= 1024 && unit < units.length - 1) {
541
- value /= 1024;
542
- unit++;
543
- }
544
- return `${value >= 10 || unit === 0 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
545
- }
546
- function formatDuration(seconds) {
547
- if (!Number.isFinite(seconds) || seconds <= 0) return "";
548
- const s = Math.round(seconds);
549
- const h = Math.floor(s / 3600);
550
- const m = Math.floor(s % 3600 / 60);
551
- const sec = s % 60;
552
- const mm = h > 0 ? String(m).padStart(2, "0") : String(m);
553
- const ss = String(sec).padStart(2, "0");
554
- return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
555
- }
556
- function truncate(text, max) {
557
- return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
558
- }
559
- function shortenPath(filepath, homedir, max = 60) {
560
- const pretty = filepath.startsWith(homedir) ? `~${filepath.slice(homedir.length)}` : filepath;
561
- if (pretty.length <= max) return pretty;
562
- const ext = /\.\w{1,5}$/.exec(pretty)?.[0] ?? "";
563
- return `${pretty.slice(0, max - ext.length - 1)}\u2026${ext}`;
564
- }
565
- function wrapText(text, width) {
566
- const lines = [];
567
- let line = "";
568
- for (const word of text.split(/\s+/).filter(Boolean)) {
569
- if (!line) line = word;
570
- else if (line.length + 1 + word.length <= width) line += ` ${word}`;
571
- else {
572
- lines.push(line);
573
- line = word;
574
- }
575
- }
576
- if (line) lines.push(line);
577
- return lines;
578
- }
579
- function formatSpeed(bytesPerSecond) {
580
- if (!Number.isFinite(bytesPerSecond) || bytesPerSecond <= 0) return "";
581
- return `${formatBytes(bytesPerSecond)}/s`;
582
- }
583
- function formatEta(seconds) {
584
- if (!Number.isFinite(seconds) || seconds <= 0) return "";
585
- return formatDuration(seconds);
586
- }
587
-
588
648
  // src/lib/history.ts
589
649
  import fs from "fs";
590
650
  import os from "os";
@@ -700,9 +760,17 @@ async function findFfmpeg() {
700
760
  }
701
761
  return void 0;
702
762
  }
703
- async function probe(ytdlp, url, signal) {
763
+ function resolveEntryUrl(entry) {
764
+ if (entry.webpage_url) return entry.webpage_url;
765
+ if (entry.url && /^https?:\/\//i.test(entry.url)) return entry.url;
766
+ if (entry.id && entry.ie_key?.toLowerCase().includes("youtube")) {
767
+ return `https://www.youtube.com/watch?v=${entry.id}`;
768
+ }
769
+ return entry.url;
770
+ }
771
+ async function probe(ytdlp, url, signal, depth = 0) {
704
772
  const stdout = await new Promise((resolve, reject) => {
705
- const child = spawn(ytdlp, ["-J", "--no-playlist", "--no-warnings", url], { signal });
773
+ const child = spawn(ytdlp, ["-J", "--no-playlist", "--flat-playlist", "--no-warnings", url], { signal });
706
774
  let out = "";
707
775
  let stderr = "";
708
776
  child.stdout.on("data", (chunk) => out += chunk);
@@ -716,15 +784,29 @@ async function probe(ytdlp, url, signal) {
716
784
  }
717
785
  });
718
786
  });
719
- let info;
787
+ let data;
720
788
  try {
721
- info = JSON.parse(stdout);
789
+ data = JSON.parse(stdout);
722
790
  } catch {
723
791
  throw new Error("Could not parse video info from yt-dlp.");
724
792
  }
793
+ if (Array.isArray(data.entries)) {
794
+ const mapped = data.entries.map((entry) => ({
795
+ url: resolveEntryUrl(entry),
796
+ title: entry.title,
797
+ duration: entry.duration
798
+ }));
799
+ const entries = mapped.filter((entry) => Boolean(entry.url));
800
+ if (entries.length > 1) return { kind: "playlist", title: data.title || "Playlist", entries };
801
+ if (entries.length === 1) {
802
+ if (depth >= 3) throw new Error("Could not resolve this playlist entry.");
803
+ return probe(ytdlp, entries[0].url, signal, depth + 1);
804
+ }
805
+ throw new Error("This playlist has no downloadable videos.");
806
+ }
725
807
  const infoJsonPath = path2.join(os2.tmpdir(), `yeet-info-${process.pid}-${Date.now()}.json`);
726
808
  await fs2.writeFile(infoJsonPath, stdout);
727
- return { info, infoJsonPath };
809
+ return { kind: "video", info: data, infoJsonPath };
728
810
  }
729
811
  var MAX_VIDEO_CHOICES = 8;
730
812
  function buildChoices(info) {
@@ -773,6 +855,25 @@ function scoreVideo(f) {
773
855
  if (f.vcodec?.startsWith("avc")) score += 5e3;
774
856
  return score;
775
857
  }
858
+ var QUEUE_PRESETS = [
859
+ { kind: "video", label: "best available \xB7 mp4", args: ["-f", "bv*+ba/b", "--merge-output-format", "mp4"] },
860
+ {
861
+ kind: "video",
862
+ label: "1080p \xB7 mp4",
863
+ args: ["-f", "bv*[height<=1080]+ba/b[height<=1080]/b", "--merge-output-format", "mp4"]
864
+ },
865
+ {
866
+ kind: "video",
867
+ label: "720p \xB7 mp4",
868
+ args: ["-f", "bv*[height<=720]+ba/b[height<=720]/b", "--merge-output-format", "mp4"]
869
+ },
870
+ {
871
+ kind: "video",
872
+ label: "480p \xB7 mp4",
873
+ args: ["-f", "bv*[height<=480]+ba/b[height<=480]/b", "--merge-output-format", "mp4"]
874
+ },
875
+ { kind: "audio", label: "audio only \xB7 mp3", args: ["-f", "ba/b", "-x", "--audio-format", "mp3", "--audio-quality", "0"] }
876
+ ];
776
877
  var PROGRESS_PREFIX = "YEET|";
777
878
  var PROGRESS_TEMPLATE = `${PROGRESS_PREFIX}%(progress.downloaded_bytes)s|%(progress.total_bytes)s|%(progress.total_bytes_estimate)s|%(progress.speed)s|%(progress.eta)s`;
778
879
  var activeChild;
@@ -876,7 +977,7 @@ function cleanYtDlpError(stderr) {
876
977
  }
877
978
 
878
979
  // src/app.tsx
879
- import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
980
+ import { Fragment as Fragment2, jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
880
981
  var OUT_DIR = path3.join(os3.homedir(), "Downloads");
881
982
  var YEET_BUTTON = "yeet";
882
983
  var DONE_LABEL = "\u21B5 yeet another";
@@ -884,13 +985,13 @@ var TAGLINE = "yeet any video. paste. yeet. done.";
884
985
  var choiceLabel = (choice) => `${choice.kind === "audio" ? "\u266A " : "\u25B6 "}${choice.label}`;
885
986
  function ChoiceIndicator({ isSelected }) {
886
987
  const theme = useTheme();
887
- return /* @__PURE__ */ jsx8(Box5, { marginRight: 1, children: /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: isSelected ? "\u276F" : " " }) });
988
+ return /* @__PURE__ */ jsx9(Box6, { marginRight: 1, children: /* @__PURE__ */ jsx9(Text8, { color: theme.primary, children: isSelected ? "\u276F" : " " }) });
888
989
  }
889
990
  function ChoiceItem({ isSelected, label }) {
890
991
  const theme = useTheme();
891
- return /* @__PURE__ */ jsx8(Text7, { color: theme.primary, bold: isSelected, children: label });
992
+ return /* @__PURE__ */ jsx9(Text8, { color: theme.primary, bold: isSelected, children: label });
892
993
  }
893
- var Gap = ({ lines = 1 }) => /* @__PURE__ */ jsx8(Box5, { flexDirection: "column", flexShrink: 0, children: Array.from({ length: lines }, (_, i) => /* @__PURE__ */ jsx8(Text7, { children: " " }, i)) });
994
+ var Gap = ({ lines = 1 }) => /* @__PURE__ */ jsx9(Box6, { flexDirection: "column", flexShrink: 0, children: Array.from({ length: lines }, (_, i) => /* @__PURE__ */ jsx9(Text8, { children: " " }, i)) });
894
995
  function partLabel(progress) {
895
996
  return progress.totalParts > 1 ? `part ${progress.part + 1}/${progress.totalParts} ` : "";
896
997
  }
@@ -904,6 +1005,48 @@ function indeterminateMeta(progress) {
904
1005
  const speed = progress.speed ? formatSpeed(progress.speed) : "";
905
1006
  return `${partLabel(progress)}${bytes.padStart(8)} ${speed.padEnd(10)}`;
906
1007
  }
1008
+ function DownloadStatus({
1009
+ theme,
1010
+ progress,
1011
+ processing,
1012
+ refreshing
1013
+ }) {
1014
+ if (processing) {
1015
+ return /* @__PURE__ */ jsxs7(Fragment2, { children: [
1016
+ /* @__PURE__ */ jsx9(ProgressBar, { percent: 1 }),
1017
+ /* @__PURE__ */ jsx9(Gap, {}),
1018
+ /* @__PURE__ */ jsxs7(Text8, { children: [
1019
+ /* @__PURE__ */ jsx9(Text8, { color: theme.primary, children: /* @__PURE__ */ jsx9(Spinner2, { type: "dots" }) }),
1020
+ /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: " processing\u2026" })
1021
+ ] })
1022
+ ] });
1023
+ }
1024
+ if (progress?.totalBytes) {
1025
+ return /* @__PURE__ */ jsxs7(Fragment2, { children: [
1026
+ /* @__PURE__ */ jsx9(ProgressBar, { percent: progress.downloadedBytes / progress.totalBytes }),
1027
+ /* @__PURE__ */ jsx9(Gap, {}),
1028
+ /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: downloadMeta(progress) })
1029
+ ] });
1030
+ }
1031
+ if (progress) {
1032
+ return /* @__PURE__ */ jsxs7(Fragment2, { children: [
1033
+ /* @__PURE__ */ jsxs7(Text8, { children: [
1034
+ /* @__PURE__ */ jsx9(Text8, { color: theme.primary, children: /* @__PURE__ */ jsx9(Spinner2, { type: "dots" }) }),
1035
+ /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: " downloading\u2026" })
1036
+ ] }),
1037
+ /* @__PURE__ */ jsx9(Gap, {}),
1038
+ /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: indeterminateMeta(progress) })
1039
+ ] });
1040
+ }
1041
+ return /* @__PURE__ */ jsxs7(Fragment2, { children: [
1042
+ /* @__PURE__ */ jsx9(ProgressBar, { percent: 0 }),
1043
+ /* @__PURE__ */ jsx9(Gap, {}),
1044
+ /* @__PURE__ */ jsxs7(Text8, { children: [
1045
+ /* @__PURE__ */ jsx9(Text8, { color: theme.primary, children: /* @__PURE__ */ jsx9(Spinner2, { type: "dots" }) }),
1046
+ /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: refreshing ? " link expired \u2014 grabbing a fresh one\u2026" : " starting download\u2026" })
1047
+ ] })
1048
+ ] });
1049
+ }
907
1050
  var HINTS = {
908
1051
  input: [
909
1052
  ["\u21B5", "yeet"],
@@ -924,6 +1067,21 @@ var HINTS = {
924
1067
  ["^c", "quit"]
925
1068
  ],
926
1069
  done: [["^c", "quit"]],
1070
+ "batch-picking": [
1071
+ ["\u2191\u2193", "choose"],
1072
+ ["\u21B5", "yeet all"],
1073
+ ["esc", "back"],
1074
+ ["^c", "quit"]
1075
+ ],
1076
+ "batch-running": [
1077
+ ["s", "skip"],
1078
+ ["esc", "cancel"],
1079
+ ["^c", "quit"]
1080
+ ],
1081
+ "batch-done": [
1082
+ ["\u21B5", "done"],
1083
+ ["^c", "quit"]
1084
+ ],
927
1085
  error: [
928
1086
  ["\u21B5", "try again"],
929
1087
  ["^c", "quit"]
@@ -934,7 +1092,7 @@ function App({ initialThemeMode: initialThemeMode2 = "auto", ...props }) {
934
1092
  const cycleTheme = useCallback(() => {
935
1093
  setThemeMode(nextThemeMode);
936
1094
  }, []);
937
- return /* @__PURE__ */ jsx8(ThemeProvider, { mode: themeMode, children: /* @__PURE__ */ jsx8(AppContent, { ...props, cycleTheme }) });
1095
+ return /* @__PURE__ */ jsx9(ThemeProvider, { mode: themeMode, children: /* @__PURE__ */ jsx9(AppContent, { ...props, cycleTheme }) });
938
1096
  }
939
1097
  function AppContent({
940
1098
  initialUrl: initialUrl2,
@@ -953,12 +1111,18 @@ function AppContent({
953
1111
  const [choices, setChoices] = useState4([]);
954
1112
  const ytdlpRef = useRef3("");
955
1113
  const highlightRef = useRef3(0);
1114
+ const batchHighlightRef = useRef3(0);
956
1115
  const infoJsonRef = useRef3(void 0);
957
1116
  const abortRef = useRef3(void 0);
958
1117
  const [phase, setPhase] = useState4(initialUrl2 ? { name: "probing", status: "warming up\u2026" } : { name: "input" });
1118
+ const [queue, setQueue] = useState4([]);
1119
+ const [batchLabel, setBatchLabel] = useState4("");
1120
+ const [batchItems, setBatchItems] = useState4([]);
1121
+ const batchControlRef = useRef3({ cancelled: false });
959
1122
  const columns = stdout?.columns && stdout.columns > 0 ? stdout.columns : 80;
960
1123
  const boxWidth = Math.max(14, Math.min(64, columns - 6));
961
1124
  const contentWidth = Math.max(10, Math.min(columns - 4, 78));
1125
+ const batchWidth = Math.max(20, Math.min(columns - 10, 60));
962
1126
  const startProbe = useCallback(async (targetUrl) => {
963
1127
  const controller = new AbortController();
964
1128
  abortRef.current = controller;
@@ -969,11 +1133,17 @@ function AppContent({
969
1133
  ytdlpRef.current = ytdlp;
970
1134
  if (controller.signal.aborted) return;
971
1135
  setPhase({ name: "probing", status: "fetching video info\u2026" });
972
- const { info: videoInfo, infoJsonPath } = await probe(ytdlp, targetUrl, controller.signal);
1136
+ const result = await probe(ytdlp, targetUrl, controller.signal);
973
1137
  if (controller.signal.aborted) return;
974
- infoJsonRef.current = infoJsonPath;
975
- setInfo(videoInfo);
976
- setChoices(buildChoices(videoInfo));
1138
+ if (result.kind === "playlist") {
1139
+ setBatchLabel(result.title);
1140
+ setBatchItems(makeBatchItems(result.entries));
1141
+ setPhase({ name: "batch-picking" });
1142
+ return;
1143
+ }
1144
+ infoJsonRef.current = result.infoJsonPath;
1145
+ setInfo(result.info);
1146
+ setChoices(buildChoices(result.info));
977
1147
  highlightRef.current = 0;
978
1148
  setPhase({ name: "picking" });
979
1149
  } catch (error) {
@@ -990,6 +1160,8 @@ function AppContent({
990
1160
  setPlatform(void 0);
991
1161
  setInfo(void 0);
992
1162
  setChoices([]);
1163
+ setBatchLabel("");
1164
+ setBatchItems([]);
993
1165
  setPhase({ name: "input" });
994
1166
  }, []);
995
1167
  const cancelRun = useCallback(() => {
@@ -997,20 +1169,120 @@ function AppContent({
997
1169
  resetToInput();
998
1170
  setUrlInput(url);
999
1171
  }, [resetToInput, url]);
1172
+ const startBatch = useCallback((initialItems, preset) => {
1173
+ batchControlRef.current = { cancelled: false };
1174
+ let items = initialItems.map((item) => ({ ...item }));
1175
+ setBatchItems(items);
1176
+ setPhase({ name: "batch-running", preset });
1177
+ void (async () => {
1178
+ let ytdlp;
1179
+ try {
1180
+ ytdlp = ytdlpRef.current || await ensureYtDlp(() => {
1181
+ });
1182
+ ytdlpRef.current = ytdlp;
1183
+ } catch (error) {
1184
+ items = items.map((item) => ({ ...item, status: "error", error: error instanceof Error ? error.message : String(error) }));
1185
+ setBatchItems(items);
1186
+ setPhase({ name: "batch-done" });
1187
+ return;
1188
+ }
1189
+ const ffmpegLocation = await findFfmpeg();
1190
+ for (let index = 0; index < items.length; index++) {
1191
+ if (batchControlRef.current.cancelled) {
1192
+ items = items.map((item, i) => i >= index && item.status === "pending" ? { ...item, status: "skipped" } : item);
1193
+ setBatchItems(items);
1194
+ break;
1195
+ }
1196
+ const controller = new AbortController();
1197
+ batchControlRef.current.controller = controller;
1198
+ items = items.map((item, i) => i === index ? { ...item, status: "active" } : item);
1199
+ setBatchItems(items);
1200
+ try {
1201
+ const handlers = {
1202
+ onProgress: (progress) => {
1203
+ items = items.map((item, i) => i === index ? { ...item, progress, processing: false } : item);
1204
+ setBatchItems(items);
1205
+ },
1206
+ onProcessing: () => {
1207
+ items = items.map((item, i) => i === index ? { ...item, processing: true } : item);
1208
+ setBatchItems(items);
1209
+ }
1210
+ };
1211
+ const filepath = await download(
1212
+ { ytdlp, ffmpegLocation, url: items[index].url, choice: preset, outDir: OUT_DIR },
1213
+ handlers,
1214
+ controller.signal
1215
+ );
1216
+ items = items.map((item, i) => i === index ? { ...item, status: "done", filepath } : item);
1217
+ setBatchItems(items);
1218
+ setHistory(addToHistory(items[index].url));
1219
+ } catch (error) {
1220
+ if (controller.signal.aborted) {
1221
+ items = items.map((item, i) => i === index ? { ...item, status: "skipped" } : item);
1222
+ } else {
1223
+ items = items.map(
1224
+ (item, i) => i === index ? { ...item, status: "error", error: error instanceof Error ? error.message : String(error) } : item
1225
+ );
1226
+ }
1227
+ setBatchItems(items);
1228
+ if (batchControlRef.current.cancelled) {
1229
+ items = items.map((item, i) => i > index ? { ...item, status: "skipped" } : item);
1230
+ setBatchItems(items);
1231
+ break;
1232
+ }
1233
+ }
1234
+ }
1235
+ onOutcome({ count: items.filter((item) => item.status === "done").length });
1236
+ setPhase({ name: "batch-done" });
1237
+ })();
1238
+ }, [onOutcome]);
1000
1239
  useInput2(
1001
1240
  (input, key) => {
1002
1241
  if (key.ctrl && input === "t") {
1003
1242
  cycleTheme();
1004
1243
  return;
1005
1244
  }
1006
- if (key.escape && (phase.name === "picking" || phase.name === "error" || phase.name === "done")) resetToInput();
1007
- if (key.escape && (phase.name === "probing" || phase.name === "downloading")) cancelRun();
1008
- if (key.return && (phase.name === "error" || phase.name === "done")) resetToInput();
1245
+ if (key.ctrl && input === "q" && phase.name === "input") {
1246
+ const trimmed = urlInput.trim();
1247
+ if (isProbablyUrl(trimmed)) {
1248
+ setQueue((q) => [...q, trimmed]);
1249
+ setUrlInput("");
1250
+ }
1251
+ return;
1252
+ }
1253
+ if (input === "s" && phase.name === "batch-running") {
1254
+ batchControlRef.current.controller?.abort();
1255
+ return;
1256
+ }
1257
+ if (key.escape && phase.name === "batch-running") {
1258
+ batchControlRef.current.cancelled = true;
1259
+ batchControlRef.current.controller?.abort();
1260
+ return;
1261
+ }
1262
+ if (key.escape && (phase.name === "picking" || phase.name === "batch-picking" || phase.name === "error" || phase.name === "done" || phase.name === "batch-done")) {
1263
+ resetToInput();
1264
+ }
1265
+ if (key.escape && phase.name === "probing") cancelRun();
1266
+ if (key.return && (phase.name === "error" || phase.name === "done" || phase.name === "batch-done")) resetToInput();
1009
1267
  },
1010
1268
  { isActive: Boolean(process.stdin.isTTY) }
1011
1269
  );
1012
1270
  const handleUrlSubmit = (value) => {
1013
1271
  const trimmed = value.trim();
1272
+ if (queue.length > 0) {
1273
+ if (trimmed && !isProbablyUrl(trimmed)) {
1274
+ setPhase({ name: "input", warning: "that doesn\u2019t look like a link \u2014 clear it or paste a full url" });
1275
+ return;
1276
+ }
1277
+ const urls = trimmed ? [...queue, trimmed] : [...queue];
1278
+ setQueue([]);
1279
+ setUrlInput("");
1280
+ const items = makeBatchItems(urls.map((u) => ({ url: u })));
1281
+ setBatchLabel(`${items.length} queued link${items.length === 1 ? "" : "s"}`);
1282
+ setBatchItems(items);
1283
+ setPhase({ name: "batch-picking" });
1284
+ return;
1285
+ }
1014
1286
  if (!isProbablyUrl(trimmed)) {
1015
1287
  setPhase({ name: "input", warning: "that doesn\u2019t look like a link \u2014 paste a full url" });
1016
1288
  return;
@@ -1018,7 +1290,7 @@ function AppContent({
1018
1290
  setUrl(trimmed);
1019
1291
  void startProbe(trimmed);
1020
1292
  };
1021
- const clipboardOffered = Boolean(clipboardUrl2) && urlInput === "";
1293
+ const clipboardOffered = Boolean(clipboardUrl2) && urlInput === "" && queue.length === 0;
1022
1294
  const clipboardAccepted = Boolean(clipboardUrl2) && urlInput === clipboardUrl2;
1023
1295
  const handlePick = (item) => {
1024
1296
  const choice = choices[item.value];
@@ -1052,18 +1324,39 @@ function AppContent({
1052
1324
  }
1053
1325
  })();
1054
1326
  };
1327
+ const handleBatchPick = (item) => {
1328
+ const preset = QUEUE_PRESETS[item.value];
1329
+ startBatch(batchItems, preset);
1330
+ };
1055
1331
  let hints = [...HINTS[phase.name], ["^t", `theme:${theme.mode}`]];
1056
- if (phase.name === "input" && history.length > 0) {
1057
- hints = [hints[0], ["\u2191", "history"], ...hints.slice(1)];
1332
+ if (phase.name === "input") {
1333
+ if (queue.length > 0) {
1334
+ hints = [hints[0], ["^q", `queued (${queue.length})`], ...hints.slice(1)];
1335
+ } else if (isProbablyUrl(urlInput.trim())) {
1336
+ hints = [hints[0], ["^q", "add to queue"], ...hints.slice(1)];
1337
+ } else if (history.length > 0) {
1338
+ hints = [hints[0], ["\u2191", "history"], ...hints.slice(1)];
1339
+ }
1058
1340
  }
1059
1341
  const hintAction = (key) => {
1060
1342
  if (key === "^c") return () => exit();
1061
1343
  if (key === "^t") return cycleTheme;
1062
- if (key === "esc") return phase.name === "probing" || phase.name === "downloading" ? cancelRun : resetToInput;
1344
+ if (key === "esc") {
1345
+ if (phase.name === "probing") return cancelRun;
1346
+ if (phase.name === "batch-running") {
1347
+ return () => {
1348
+ batchControlRef.current.cancelled = true;
1349
+ batchControlRef.current.controller?.abort();
1350
+ };
1351
+ }
1352
+ return resetToInput;
1353
+ }
1354
+ if (key === "s" && phase.name === "batch-running") return () => batchControlRef.current.controller?.abort();
1063
1355
  if (key === "\u21B5") {
1064
1356
  if (phase.name === "input") return () => handleUrlSubmit(urlInput);
1065
1357
  if (phase.name === "picking") return () => handlePick({ value: highlightRef.current });
1066
- if (phase.name === "error" || phase.name === "done") return resetToInput;
1358
+ if (phase.name === "batch-picking") return () => handleBatchPick({ value: batchHighlightRef.current });
1359
+ if (phase.name === "error" || phase.name === "done" || phase.name === "batch-done") return resetToInput;
1067
1360
  }
1068
1361
  return void 0;
1069
1362
  };
@@ -1076,7 +1369,12 @@ function AppContent({
1076
1369
  clickTargets.push({ match: choiceLabel(choice), action: () => handlePick({ value: index }) });
1077
1370
  }
1078
1371
  }
1079
- if (phase.name === "done") {
1372
+ if (phase.name === "batch-picking") {
1373
+ for (const [index, choice] of QUEUE_PRESETS.entries()) {
1374
+ clickTargets.push({ match: choiceLabel(choice), action: () => handleBatchPick({ value: index }) });
1375
+ }
1376
+ }
1377
+ if (phase.name === "done" || phase.name === "batch-done") {
1080
1378
  clickTargets.push({ match: DONE_LABEL, padX: 4, padY: 1, action: resetToInput });
1081
1379
  }
1082
1380
  for (const [key, label] of hints) {
@@ -1089,8 +1387,11 @@ function AppContent({
1089
1387
  if (taglineRow > 3 && y - 1 >= taglineRow - 4 && y - 1 <= taglineRow - 2) {
1090
1388
  const span = frameRowSpan(y - 1);
1091
1389
  if (span && x >= span[0] - 1 && x <= span[1] + 1) {
1092
- if (phase.name === "probing" || phase.name === "downloading") cancelRun();
1093
- else if (phase.name !== "input") resetToInput();
1390
+ if (phase.name === "probing") cancelRun();
1391
+ else if (phase.name === "batch-running") {
1392
+ batchControlRef.current.cancelled = true;
1393
+ batchControlRef.current.controller?.abort();
1394
+ } else if (phase.name !== "input") resetToInput();
1094
1395
  return;
1095
1396
  }
1096
1397
  }
@@ -1098,14 +1399,16 @@ function AppContent({
1098
1399
  },
1099
1400
  Boolean(process.stdin.isTTY)
1100
1401
  );
1101
- return /* @__PURE__ */ jsxs6(FullScreen, { children: [
1102
- /* @__PURE__ */ jsx8(Logo, {}),
1103
- /* @__PURE__ */ jsx8(Gap, {}),
1104
- /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: TAGLINE }),
1105
- /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: "youtube \xB7 x \xB7 instagram \xB7 threads \xB7 tiktok \xB7 +1800 more" }),
1106
- /* @__PURE__ */ jsx8(Gap, {}),
1107
- phase.name === "input" && /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", alignItems: "center", children: [
1108
- /* @__PURE__ */ jsx8(FramedInput, { title: "Paste a link", width: boxWidth, button: YEET_BUTTON, children: /* @__PURE__ */ jsx8(
1402
+ const batchActive = batchItems.find((item) => item.status === "active");
1403
+ const batchSummaryCounts = batchSummary(batchItems);
1404
+ return /* @__PURE__ */ jsxs7(FullScreen, { children: [
1405
+ /* @__PURE__ */ jsx9(Logo, {}),
1406
+ /* @__PURE__ */ jsx9(Gap, {}),
1407
+ /* @__PURE__ */ jsx9(Text8, { color: theme.primary, children: TAGLINE }),
1408
+ /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: "youtube \xB7 x \xB7 instagram \xB7 threads \xB7 tiktok \xB7 +1800 more" }),
1409
+ /* @__PURE__ */ jsx9(Gap, {}),
1410
+ phase.name === "input" && /* @__PURE__ */ jsxs7(Box6, { flexDirection: "column", alignItems: "center", children: [
1411
+ /* @__PURE__ */ jsx9(FramedInput, { title: "Paste a link", width: boxWidth, button: YEET_BUTTON, children: /* @__PURE__ */ jsx9(
1109
1412
  TextInput,
1110
1413
  {
1111
1414
  value: urlInput,
@@ -1120,24 +1423,29 @@ function AppContent({
1120
1423
  }
1121
1424
  }
1122
1425
  ) }),
1123
- phase.warning ? /* @__PURE__ */ jsxs6(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1426
+ phase.warning ? /* @__PURE__ */ jsxs7(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1124
1427
  "\u2717 ",
1125
1428
  phase.warning
1126
- ] }) : clipboardOffered ? /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: "link in your clipboard \u2014 \u21E5 to paste it" }) : clipboardAccepted ? /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: "from your clipboard \u2014 \u21B5 to yeet it" }) : null
1429
+ ] }) : queue.length > 0 ? /* @__PURE__ */ jsxs7(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1430
+ queue.length,
1431
+ " link",
1432
+ queue.length === 1 ? "" : "s",
1433
+ " queued \u2014 \u21B5 to start, ^q to add another"
1434
+ ] }) : clipboardOffered ? /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: "link in your clipboard \u2014 \u21E5 to paste it" }) : clipboardAccepted ? /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: "from your clipboard \u2014 \u21B5 to yeet it" }) : null
1127
1435
  ] }),
1128
- phase.name === "probing" && /* @__PURE__ */ jsx8(Box5, { flexDirection: "column", alignItems: "center", children: /* @__PURE__ */ jsx8(FramedInput, { title: platform ? platform.label : "Paste a link", width: boxWidth, button: YEET_BUTTON, buttonDim: true, children: /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: url.length > boxWidth - 8 ? `${url.slice(0, boxWidth - 9)}\u2026` : url }) }) }),
1129
- phase.name === "picking" && platform && /* @__PURE__ */ jsxs6(Box5, { width: contentWidth, children: [
1130
- /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", flexGrow: 1, flexBasis: 0, paddingTop: 1, paddingRight: 3, children: [
1131
- wrapText(info?.title ?? "", Math.max(10, contentWidth - 41)).map((line, index) => /* @__PURE__ */ jsx8(Text7, { bold: true, color: theme.primary, children: line }, index)),
1132
- /* @__PURE__ */ jsx8(Gap, {}),
1133
- /* @__PURE__ */ jsxs6(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1436
+ phase.name === "probing" && /* @__PURE__ */ jsx9(Box6, { flexDirection: "column", alignItems: "center", children: /* @__PURE__ */ jsx9(FramedInput, { title: platform ? platform.label : "Paste a link", width: boxWidth, button: YEET_BUTTON, buttonDim: true, children: /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: url.length > boxWidth - 8 ? `${url.slice(0, boxWidth - 9)}\u2026` : url }) }) }),
1437
+ phase.name === "picking" && platform && /* @__PURE__ */ jsxs7(Box6, { width: contentWidth, children: [
1438
+ /* @__PURE__ */ jsxs7(Box6, { flexDirection: "column", flexGrow: 1, flexBasis: 0, paddingTop: 1, paddingRight: 3, children: [
1439
+ wrapText(info?.title ?? "", Math.max(10, contentWidth - 41)).map((line, index) => /* @__PURE__ */ jsx9(Text8, { bold: true, color: theme.primary, children: line }, index)),
1440
+ /* @__PURE__ */ jsx9(Gap, {}),
1441
+ /* @__PURE__ */ jsxs7(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1134
1442
  "\u25B8 ",
1135
1443
  platform.label,
1136
1444
  info?.duration ? ` \xB7 ${formatDuration(info.duration)}` : "",
1137
1445
  info?.uploader ? ` \xB7 ${info.uploader}` : ""
1138
1446
  ] })
1139
1447
  ] }),
1140
- /* @__PURE__ */ jsx8(Panel, { title: "Download", width: 38, children: /* @__PURE__ */ jsx8(
1448
+ /* @__PURE__ */ jsx9(Panel, { title: "Download", width: 38, children: /* @__PURE__ */ jsx9(
1141
1449
  SelectInput,
1142
1450
  {
1143
1451
  indicatorComponent: ChoiceIndicator,
@@ -1152,71 +1460,130 @@ function AppContent({
1152
1460
  }
1153
1461
  ) })
1154
1462
  ] }),
1155
- phase.name === "downloading" && /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", alignItems: "center", children: [
1156
- /* @__PURE__ */ jsxs6(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1463
+ phase.name === "batch-picking" && /* @__PURE__ */ jsxs7(Box6, { width: contentWidth, children: [
1464
+ /* @__PURE__ */ jsxs7(Box6, { flexDirection: "column", flexGrow: 1, flexBasis: 0, paddingTop: 1, paddingRight: 3, children: [
1465
+ wrapText(batchLabel, Math.max(10, contentWidth - 41)).map((line, index) => /* @__PURE__ */ jsx9(Text8, { bold: true, color: theme.primary, children: line }, index)),
1466
+ /* @__PURE__ */ jsx9(Gap, {}),
1467
+ /* @__PURE__ */ jsxs7(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1468
+ "\u25B8 ",
1469
+ batchItems.length,
1470
+ " video",
1471
+ batchItems.length === 1 ? "" : "s",
1472
+ " \xB7 same format for all"
1473
+ ] }),
1474
+ /* @__PURE__ */ jsx9(Gap, {}),
1475
+ /* @__PURE__ */ jsx9(QueueList, { items: batchItems, width: Math.max(10, contentWidth - 41) })
1476
+ ] }),
1477
+ /* @__PURE__ */ jsx9(Panel, { title: "Download", width: 38, children: /* @__PURE__ */ jsx9(
1478
+ SelectInput,
1479
+ {
1480
+ indicatorComponent: ChoiceIndicator,
1481
+ itemComponent: ChoiceItem,
1482
+ items: QUEUE_PRESETS.map((choice, index) => ({
1483
+ key: String(index),
1484
+ label: choiceLabel(choice),
1485
+ value: index
1486
+ })),
1487
+ onSelect: handleBatchPick,
1488
+ onHighlight: (item) => batchHighlightRef.current = item.value
1489
+ }
1490
+ ) })
1491
+ ] }),
1492
+ phase.name === "downloading" && /* @__PURE__ */ jsxs7(Box6, { flexDirection: "column", alignItems: "center", children: [
1493
+ /* @__PURE__ */ jsxs7(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1157
1494
  info?.title ? `${truncate(info.title, 42)} \xB7 ` : "",
1158
1495
  phase.choice.label
1159
1496
  ] }),
1160
- /* @__PURE__ */ jsx8(Gap, {}),
1161
- phase.processing ? /* @__PURE__ */ jsxs6(Fragment2, { children: [
1162
- /* @__PURE__ */ jsx8(ProgressBar, { percent: 1 }),
1163
- /* @__PURE__ */ jsx8(Gap, {}),
1164
- /* @__PURE__ */ jsxs6(Text7, { children: [
1165
- /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: /* @__PURE__ */ jsx8(Spinner, { type: "dots" }) }),
1166
- /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: " processing\u2026" })
1167
- ] })
1168
- ] }) : phase.progress?.totalBytes ? /* @__PURE__ */ jsxs6(Fragment2, { children: [
1169
- /* @__PURE__ */ jsx8(ProgressBar, { percent: phase.progress.downloadedBytes / phase.progress.totalBytes }),
1170
- /* @__PURE__ */ jsx8(Gap, {}),
1171
- /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: downloadMeta(phase.progress) })
1172
- ] }) : phase.progress ? /* @__PURE__ */ jsxs6(Fragment2, { children: [
1173
- /* @__PURE__ */ jsxs6(Text7, { children: [
1174
- /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: /* @__PURE__ */ jsx8(Spinner, { type: "dots" }) }),
1175
- /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: " downloading\u2026" })
1176
- ] }),
1177
- /* @__PURE__ */ jsx8(Gap, {}),
1178
- /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: indeterminateMeta(phase.progress) })
1179
- ] }) : /* @__PURE__ */ jsxs6(Fragment2, { children: [
1180
- /* @__PURE__ */ jsx8(ProgressBar, { percent: 0 }),
1181
- /* @__PURE__ */ jsx8(Gap, {}),
1182
- /* @__PURE__ */ jsxs6(Text7, { children: [
1183
- /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: /* @__PURE__ */ jsx8(Spinner, { type: "dots" }) }),
1184
- /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: phase.refreshing ? " link expired \u2014 grabbing a fresh one\u2026" : " starting download\u2026" })
1185
- ] })
1497
+ /* @__PURE__ */ jsx9(Gap, {}),
1498
+ /* @__PURE__ */ jsx9(DownloadStatus, { theme, progress: phase.progress, processing: phase.processing, refreshing: phase.refreshing })
1499
+ ] }),
1500
+ phase.name === "batch-running" && /* @__PURE__ */ jsxs7(Box6, { flexDirection: "column", alignItems: "center", children: [
1501
+ /* @__PURE__ */ jsx9(QueueList, { items: batchItems, width: batchWidth }),
1502
+ /* @__PURE__ */ jsx9(Gap, {}),
1503
+ /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: batchActive ? truncate(batchActive.title || batchActive.url, 46) : "" }),
1504
+ /* @__PURE__ */ jsx9(Gap, {}),
1505
+ /* @__PURE__ */ jsx9(DownloadStatus, { theme, progress: batchActive?.progress, processing: batchActive?.processing }),
1506
+ /* @__PURE__ */ jsx9(Gap, {}),
1507
+ /* @__PURE__ */ jsxs7(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1508
+ batchSummaryCounts.done,
1509
+ "/",
1510
+ batchItems.length,
1511
+ " done",
1512
+ batchSummaryCounts.error ? ` \xB7 ${batchSummaryCounts.error} failed` : "",
1513
+ batchSummaryCounts.skipped ? ` \xB7 ${batchSummaryCounts.skipped} skipped` : ""
1186
1514
  ] })
1187
1515
  ] }),
1188
- phase.name === "done" && /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", alignItems: "center", children: [
1189
- /* @__PURE__ */ jsxs6(Text7, { children: [
1190
- /* @__PURE__ */ jsx8(Text7, { bold: true, color: theme.primary, children: "\u2713 yeeted! " }),
1191
- /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: "find your file in:" })
1516
+ phase.name === "done" && /* @__PURE__ */ jsxs7(Box6, { flexDirection: "column", alignItems: "center", children: [
1517
+ /* @__PURE__ */ jsxs7(Text8, { children: [
1518
+ /* @__PURE__ */ jsx9(Text8, { bold: true, color: theme.primary, children: "\u2713 yeeted! " }),
1519
+ /* @__PURE__ */ jsx9(Text8, { color: theme.primary, children: "find your file in:" })
1520
+ ] }),
1521
+ /* @__PURE__ */ jsx9(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: shortenPath(phase.filepath, os3.homedir(), 60) }),
1522
+ /* @__PURE__ */ jsx9(Gap, {}),
1523
+ /* @__PURE__ */ jsx9(
1524
+ Box6,
1525
+ {
1526
+ borderStyle: "round",
1527
+ borderColor: theme.gray,
1528
+ borderDimColor: theme.dimSecondary,
1529
+ borderBackgroundColor: theme.background,
1530
+ paddingX: 3,
1531
+ children: /* @__PURE__ */ jsx9(Text8, { bold: true, color: theme.primary, children: DONE_LABEL })
1532
+ }
1533
+ )
1534
+ ] }),
1535
+ phase.name === "batch-done" && /* @__PURE__ */ jsxs7(Box6, { flexDirection: "column", alignItems: "center", children: [
1536
+ /* @__PURE__ */ jsx9(QueueList, { items: batchItems, width: batchWidth }),
1537
+ /* @__PURE__ */ jsx9(Gap, {}),
1538
+ /* @__PURE__ */ jsxs7(Text8, { children: [
1539
+ /* @__PURE__ */ jsxs7(Text8, { bold: true, color: theme.primary, children: [
1540
+ "\u2713 ",
1541
+ batchSummaryCounts.done,
1542
+ "/",
1543
+ batchItems.length,
1544
+ " yeeted"
1545
+ ] }),
1546
+ batchSummaryCounts.error ? /* @__PURE__ */ jsxs7(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1547
+ " \xB7 ",
1548
+ batchSummaryCounts.error,
1549
+ " failed"
1550
+ ] }) : null,
1551
+ batchSummaryCounts.skipped ? /* @__PURE__ */ jsxs7(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1552
+ " \xB7 ",
1553
+ batchSummaryCounts.skipped,
1554
+ " skipped"
1555
+ ] }) : null
1556
+ ] }),
1557
+ /* @__PURE__ */ jsxs7(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1558
+ "saved to ",
1559
+ shortenPath(OUT_DIR, os3.homedir(), 60)
1192
1560
  ] }),
1193
- /* @__PURE__ */ jsx8(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: shortenPath(phase.filepath, os3.homedir(), 60) }),
1194
- /* @__PURE__ */ jsx8(Gap, {}),
1195
- /* @__PURE__ */ jsx8(
1196
- Box5,
1561
+ /* @__PURE__ */ jsx9(Gap, {}),
1562
+ /* @__PURE__ */ jsx9(
1563
+ Box6,
1197
1564
  {
1198
1565
  borderStyle: "round",
1199
1566
  borderColor: theme.gray,
1200
1567
  borderDimColor: theme.dimSecondary,
1201
1568
  borderBackgroundColor: theme.background,
1202
1569
  paddingX: 3,
1203
- children: /* @__PURE__ */ jsx8(Text7, { bold: true, color: theme.primary, children: DONE_LABEL })
1570
+ children: /* @__PURE__ */ jsx9(Text8, { bold: true, color: theme.primary, children: DONE_LABEL })
1204
1571
  }
1205
1572
  )
1206
1573
  ] }),
1207
- phase.name === "error" && /* @__PURE__ */ jsx8(Box5, { flexDirection: "column", alignItems: "center", width: Math.max(10, Math.min(columns - 6, 72)), children: /* @__PURE__ */ jsxs6(Text7, { bold: true, color: theme.primary, children: [
1574
+ phase.name === "error" && /* @__PURE__ */ jsx9(Box6, { flexDirection: "column", alignItems: "center", width: Math.max(10, Math.min(columns - 6, 72)), children: /* @__PURE__ */ jsxs7(Text8, { bold: true, color: theme.primary, children: [
1208
1575
  "\u2717 ",
1209
1576
  phase.message
1210
1577
  ] }) }),
1211
- hints.length > 0 ? /* @__PURE__ */ jsxs6(Fragment2, { children: [
1212
- /* @__PURE__ */ jsx8(Gap, { lines: 2 }),
1213
- /* @__PURE__ */ jsx8(
1578
+ hints.length > 0 ? /* @__PURE__ */ jsxs7(Fragment2, { children: [
1579
+ /* @__PURE__ */ jsx9(Gap, { lines: 2 }),
1580
+ /* @__PURE__ */ jsx9(
1214
1581
  Shortcuts,
1215
1582
  {
1216
1583
  items: hints,
1217
- leading: phase.name === "probing" ? /* @__PURE__ */ jsxs6(Text7, { children: [
1218
- /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: /* @__PURE__ */ jsx8(Spinner, { type: "dots" }) }),
1219
- /* @__PURE__ */ jsxs6(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1584
+ leading: phase.name === "probing" ? /* @__PURE__ */ jsxs7(Text8, { children: [
1585
+ /* @__PURE__ */ jsx9(Text8, { color: theme.primary, children: /* @__PURE__ */ jsx9(Spinner2, { type: "dots" }) }),
1586
+ /* @__PURE__ */ jsxs7(Text8, { color: theme.gray, dimColor: theme.dimSecondary, children: [
1220
1587
  " ",
1221
1588
  phase.status
1222
1589
  ] })
@@ -1275,7 +1642,7 @@ function readClipboard() {
1275
1642
  }
1276
1643
 
1277
1644
  // src/cli.tsx
1278
- import { jsx as jsx9 } from "react/jsx-runtime";
1645
+ import { jsx as jsx10 } from "react/jsx-runtime";
1279
1646
  var VERSION = createRequire(import.meta.url)("../package.json").version;
1280
1647
  var HELP = `
1281
1648
  yeet \u2014 yeet any video. paste. yeet. done.
@@ -1333,7 +1700,7 @@ if (isTTY) {
1333
1700
  }
1334
1701
  var outcome = {};
1335
1702
  var { waitUntilExit } = render(
1336
- /* @__PURE__ */ jsx9(
1703
+ /* @__PURE__ */ jsx10(
1337
1704
  App,
1338
1705
  {
1339
1706
  initialUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxedgar/yeet",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "yeet any video from YouTube, X, Instagram, Threads & 1800+ sites — right from your terminal. paste. yeet. done.",
5
5
  "type": "module",
6
6
  "bin": {