@maxedgar/yeet 1.0.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.
- package/LICENSE +21 -0
- package/README.md +96 -0
- package/dist/cli.js +1352 -0
- package/package.json +59 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.tsx
|
|
4
|
+
import { createRequire } from "module";
|
|
5
|
+
import { render } from "ink";
|
|
6
|
+
|
|
7
|
+
// src/app.tsx
|
|
8
|
+
import { useCallback, useEffect as useEffect4, useRef as useRef3, useState as useState4 } from "react";
|
|
9
|
+
import os3 from "os";
|
|
10
|
+
import path3 from "path";
|
|
11
|
+
import { Box as Box5, Text as Text7, useApp, useInput as useInput2, useStdout as useStdout3 } from "ink";
|
|
12
|
+
import SelectInput from "ink-select-input";
|
|
13
|
+
import Spinner from "ink-spinner";
|
|
14
|
+
|
|
15
|
+
// src/components/framed-input.tsx
|
|
16
|
+
import { Box, Text } from "ink";
|
|
17
|
+
|
|
18
|
+
// src/theme.ts
|
|
19
|
+
import React, { createContext, useContext } from "react";
|
|
20
|
+
var THEME_MODES = ["auto", "light", "dark"];
|
|
21
|
+
var themes = {
|
|
22
|
+
auto: {
|
|
23
|
+
mode: "auto",
|
|
24
|
+
// Leaving colors unset is more reliable than trying to detect whether a
|
|
25
|
+
// terminal is light or dark. ANSI defaults already follow its theme.
|
|
26
|
+
primary: void 0,
|
|
27
|
+
gray: void 0,
|
|
28
|
+
dark: void 0,
|
|
29
|
+
background: void 0,
|
|
30
|
+
dimSecondary: true,
|
|
31
|
+
inverseButton: true
|
|
32
|
+
},
|
|
33
|
+
light: {
|
|
34
|
+
mode: "light",
|
|
35
|
+
primary: "#18181b",
|
|
36
|
+
gray: "#52525b",
|
|
37
|
+
dark: "#ffffff",
|
|
38
|
+
background: "#ffffff",
|
|
39
|
+
dimSecondary: false,
|
|
40
|
+
inverseButton: false
|
|
41
|
+
},
|
|
42
|
+
dark: {
|
|
43
|
+
mode: "dark",
|
|
44
|
+
primary: "#ffffff",
|
|
45
|
+
gray: "#a1a1aa",
|
|
46
|
+
dark: "#18181b",
|
|
47
|
+
background: "#18181b",
|
|
48
|
+
dimSecondary: false,
|
|
49
|
+
inverseButton: false
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
var ThemeContext = createContext(themes.auto);
|
|
53
|
+
function themeFor(mode) {
|
|
54
|
+
return themes[mode];
|
|
55
|
+
}
|
|
56
|
+
function ThemeProvider({ mode, children }) {
|
|
57
|
+
return React.createElement(ThemeContext.Provider, { value: themeFor(mode) }, children);
|
|
58
|
+
}
|
|
59
|
+
function useTheme() {
|
|
60
|
+
return useContext(ThemeContext);
|
|
61
|
+
}
|
|
62
|
+
function isThemeMode(value) {
|
|
63
|
+
return typeof value === "string" && THEME_MODES.includes(value);
|
|
64
|
+
}
|
|
65
|
+
function nextThemeMode(mode) {
|
|
66
|
+
return THEME_MODES[(THEME_MODES.indexOf(mode) + 1) % THEME_MODES.length];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/components/framed-input.tsx
|
|
70
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
71
|
+
var frameButtonWidth = (label) => label.length + 4;
|
|
72
|
+
function FramedInput({
|
|
73
|
+
title,
|
|
74
|
+
width,
|
|
75
|
+
button,
|
|
76
|
+
buttonDim = false,
|
|
77
|
+
children
|
|
78
|
+
}) {
|
|
79
|
+
const theme = useTheme();
|
|
80
|
+
const inner = width - 2;
|
|
81
|
+
const tail = Math.max(0, inner - title.length - 3);
|
|
82
|
+
const buttonW = button ? frameButtonWidth(button) : 0;
|
|
83
|
+
const fillColor = buttonDim ? theme.gray : theme.primary;
|
|
84
|
+
return /* @__PURE__ */ jsxs(Box, { width: width + buttonW, children: [
|
|
85
|
+
/* @__PURE__ */ jsxs(Box, { flexDirection: "column", width, children: [
|
|
86
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
87
|
+
/* @__PURE__ */ jsx(Text, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u256D\u2500 " }),
|
|
88
|
+
/* @__PURE__ */ jsx(Text, { color: theme.primary, children: title }),
|
|
89
|
+
/* @__PURE__ */ jsx(Text, { color: theme.gray, dimColor: theme.dimSecondary, children: ` ${"\u2500".repeat(tail)}${button ? "\u2500" : "\u256E"}` })
|
|
90
|
+
] }),
|
|
91
|
+
/* @__PURE__ */ jsxs(Box, { width, height: 1, overflow: "hidden", children: [
|
|
92
|
+
/* @__PURE__ */ jsx(Text, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u2502 " }),
|
|
93
|
+
/* @__PURE__ */ jsx(Text, { color: theme.primary, children: "\u276F " }),
|
|
94
|
+
/* @__PURE__ */ jsx(Box, { flexGrow: 1, height: 1, overflow: "hidden", children }),
|
|
95
|
+
button ? null : /* @__PURE__ */ jsx(Text, { color: theme.gray, dimColor: theme.dimSecondary, children: " \u2502" })
|
|
96
|
+
] }),
|
|
97
|
+
/* @__PURE__ */ jsx(Text, { color: theme.gray, dimColor: theme.dimSecondary, children: `\u2570${"\u2500".repeat(inner)}${button ? "\u2500" : "\u256F"}` })
|
|
98
|
+
] }),
|
|
99
|
+
button ? /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width: buttonW, children: [
|
|
100
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: fillColor, dimColor: buttonDim && theme.dimSecondary, children: "\u2584".repeat(buttonW) }),
|
|
101
|
+
/* @__PURE__ */ jsx(
|
|
102
|
+
Text,
|
|
103
|
+
{
|
|
104
|
+
backgroundColor: theme.inverseButton ? void 0 : fillColor,
|
|
105
|
+
color: theme.inverseButton ? void 0 : theme.dark,
|
|
106
|
+
inverse: theme.inverseButton && !buttonDim,
|
|
107
|
+
dimColor: buttonDim && theme.dimSecondary,
|
|
108
|
+
bold: true,
|
|
109
|
+
children: ` ${button} `
|
|
110
|
+
}
|
|
111
|
+
),
|
|
112
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: fillColor, dimColor: buttonDim && theme.dimSecondary, children: "\u2580".repeat(buttonW) })
|
|
113
|
+
] }) : null
|
|
114
|
+
] });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/components/fullscreen.tsx
|
|
118
|
+
import { useEffect, useState } from "react";
|
|
119
|
+
import { Box as Box2, useStdout } from "ink";
|
|
120
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
121
|
+
function FullScreen({ children }) {
|
|
122
|
+
const theme = useTheme();
|
|
123
|
+
const { stdout } = useStdout();
|
|
124
|
+
const dimensions = () => ({
|
|
125
|
+
columns: stdout?.columns && stdout.columns > 0 ? stdout.columns : 80,
|
|
126
|
+
rows: stdout?.rows && stdout.rows > 1 ? stdout.rows : 24
|
|
127
|
+
});
|
|
128
|
+
const [size, setSize] = useState(dimensions);
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!stdout) return;
|
|
131
|
+
const onResize = () => setSize(dimensions());
|
|
132
|
+
stdout.on("resize", onResize);
|
|
133
|
+
return () => {
|
|
134
|
+
stdout.off("resize", onResize);
|
|
135
|
+
};
|
|
136
|
+
}, [stdout]);
|
|
137
|
+
return /* @__PURE__ */ jsx2(
|
|
138
|
+
Box2,
|
|
139
|
+
{
|
|
140
|
+
width: size.columns,
|
|
141
|
+
height: size.rows - 1,
|
|
142
|
+
flexDirection: "column",
|
|
143
|
+
alignItems: "center",
|
|
144
|
+
justifyContent: "center",
|
|
145
|
+
backgroundColor: theme.background,
|
|
146
|
+
children: /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", alignItems: "center", flexShrink: 0, children })
|
|
147
|
+
}
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/components/logo.tsx
|
|
152
|
+
import { useEffect as useEffect2, useMemo, useState as useState2 } from "react";
|
|
153
|
+
import { Box as Box3, Text as Text2 } from "ink";
|
|
154
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
155
|
+
var ART = [
|
|
156
|
+
"\u2593 \u2593 \u2588\u2580\u2580 \u2588\u2580\u2580 \u2580\u2580\u2580",
|
|
157
|
+
"\u2580\u2588\u2580 \u2588\u2580 \u2588\u2580 \u2588 ",
|
|
158
|
+
" \u2580 \u2580\u2580\u2580 \u2580\u2580\u2580 \u2580 "
|
|
159
|
+
];
|
|
160
|
+
var GRID = ART.map((line) => [...line]);
|
|
161
|
+
var ROWS = GRID.length;
|
|
162
|
+
var INTRO_MS = 900;
|
|
163
|
+
var INTRO_SPREAD_MS = 550;
|
|
164
|
+
var SWEEP_MS = 1e3;
|
|
165
|
+
var SWEEP_EVERY_MS = 7e3;
|
|
166
|
+
var TILT = 2;
|
|
167
|
+
var HALF = 2.4;
|
|
168
|
+
var LIGHTER = { "\u2588": "\u2592", "\u2593": "\u2591" };
|
|
169
|
+
var HALF_BLOCKS = /* @__PURE__ */ new Set(["\u2580", "\u2584"]);
|
|
170
|
+
var ease = (t) => 1 - Math.pow(1 - t, 3);
|
|
171
|
+
function cellAt(ch, row, col, phase, t, delay, theme) {
|
|
172
|
+
if (ch === " " || phase === "idle") return { ch, color: theme.primary, dim: false };
|
|
173
|
+
if (phase === "intro") {
|
|
174
|
+
const dt = t - delay;
|
|
175
|
+
if (dt < 0) return { ch: " ", color: theme.primary, dim: false };
|
|
176
|
+
if (dt < 110) return { ch: HALF_BLOCKS.has(ch) ? ch : "\u2591", color: theme.gray, dim: theme.dimSecondary };
|
|
177
|
+
if (dt < 220) return { ch: HALF_BLOCKS.has(ch) ? ch : "\u2592", color: theme.gray, dim: theme.dimSecondary };
|
|
178
|
+
return { ch, color: theme.primary, dim: false };
|
|
179
|
+
}
|
|
180
|
+
const cols = GRID[0].length;
|
|
181
|
+
const pMin = -TILT * ROWS - HALF;
|
|
182
|
+
const pMax = cols + HALF;
|
|
183
|
+
const p = pMin + ease(t / SWEEP_MS) * (pMax - pMin);
|
|
184
|
+
const d = Math.abs(col - (ROWS - 1 - row) * TILT - p);
|
|
185
|
+
if (d <= HALF && 1 - d / HALF > 0.35) {
|
|
186
|
+
if (HALF_BLOCKS.has(ch)) return { ch, color: theme.gray, dim: theme.dimSecondary };
|
|
187
|
+
return { ch: LIGHTER[ch] ?? ch, color: theme.primary, dim: false };
|
|
188
|
+
}
|
|
189
|
+
return { ch, color: theme.primary, dim: false };
|
|
190
|
+
}
|
|
191
|
+
function renderRow(row, phase, t, delays, theme) {
|
|
192
|
+
const segments = [];
|
|
193
|
+
GRID[row].forEach((ch, col) => {
|
|
194
|
+
const cell = cellAt(ch, row, col, phase, t, delays[col], theme);
|
|
195
|
+
const last = segments[segments.length - 1];
|
|
196
|
+
if (last && (last.color === cell.color && last.dim === cell.dim || cell.ch === " ")) last.text += cell.ch;
|
|
197
|
+
else segments.push({ text: cell.ch, color: cell.color, dim: cell.dim });
|
|
198
|
+
});
|
|
199
|
+
return segments.map((seg, i) => /* @__PURE__ */ jsx3(Text2, { color: seg.color, dimColor: seg.dim, children: seg.text }, i));
|
|
200
|
+
}
|
|
201
|
+
function Logo() {
|
|
202
|
+
const theme = useTheme();
|
|
203
|
+
const animated = Boolean(process.stdout.isTTY);
|
|
204
|
+
const delays = useMemo(
|
|
205
|
+
() => GRID.map((row) => row.map(() => Math.random() * INTRO_SPREAD_MS)),
|
|
206
|
+
[]
|
|
207
|
+
);
|
|
208
|
+
const [phase, setPhase] = useState2(animated ? "intro" : "idle");
|
|
209
|
+
const [t, setT] = useState2(0);
|
|
210
|
+
useEffect2(() => {
|
|
211
|
+
if (!animated) return;
|
|
212
|
+
if (phase === "idle") {
|
|
213
|
+
const id2 = setTimeout(() => {
|
|
214
|
+
setT(0);
|
|
215
|
+
setPhase("sweep");
|
|
216
|
+
}, SWEEP_EVERY_MS);
|
|
217
|
+
return () => clearTimeout(id2);
|
|
218
|
+
}
|
|
219
|
+
const duration = phase === "intro" ? INTRO_MS : SWEEP_MS;
|
|
220
|
+
const start = Date.now();
|
|
221
|
+
const id = setInterval(() => {
|
|
222
|
+
const elapsed = Date.now() - start;
|
|
223
|
+
if (elapsed >= duration) {
|
|
224
|
+
setT(0);
|
|
225
|
+
setPhase("idle");
|
|
226
|
+
} else {
|
|
227
|
+
setT(elapsed);
|
|
228
|
+
}
|
|
229
|
+
}, 33);
|
|
230
|
+
return () => clearInterval(id);
|
|
231
|
+
}, [phase, animated]);
|
|
232
|
+
return (
|
|
233
|
+
// flexShrink=0 — the logo must keep its 3 rows even when a phase's
|
|
234
|
+
// content would overflow the screen, or yoga crushes it first
|
|
235
|
+
/* @__PURE__ */ jsx3(Box3, { flexDirection: "column", flexShrink: 0, children: GRID.map((_, row) => /* @__PURE__ */ jsx3(Text2, { children: renderRow(row, phase, t, delays[row], theme) }, row)) })
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/components/panel.tsx
|
|
240
|
+
import { Box as Box4, Text as Text3 } from "ink";
|
|
241
|
+
import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
242
|
+
function Panel({ title, width, children }) {
|
|
243
|
+
const theme = useTheme();
|
|
244
|
+
const inner = width - 2;
|
|
245
|
+
const tail = Math.max(0, inner - title.length - 3);
|
|
246
|
+
return /* @__PURE__ */ jsxs2(Box4, { flexDirection: "column", width, children: [
|
|
247
|
+
/* @__PURE__ */ jsxs2(Text3, { children: [
|
|
248
|
+
/* @__PURE__ */ jsx4(Text3, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u256D\u2500 " }),
|
|
249
|
+
/* @__PURE__ */ jsx4(Text3, { color: theme.primary, children: title }),
|
|
250
|
+
/* @__PURE__ */ jsx4(Text3, { color: theme.gray, dimColor: theme.dimSecondary, children: ` ${"\u2500".repeat(tail)}\u256E` })
|
|
251
|
+
] }),
|
|
252
|
+
/* @__PURE__ */ jsx4(
|
|
253
|
+
Box4,
|
|
254
|
+
{
|
|
255
|
+
width,
|
|
256
|
+
borderStyle: "round",
|
|
257
|
+
borderColor: theme.gray,
|
|
258
|
+
borderDimColor: theme.dimSecondary,
|
|
259
|
+
borderBackgroundColor: theme.background,
|
|
260
|
+
borderTop: false,
|
|
261
|
+
flexDirection: "column",
|
|
262
|
+
paddingX: 2,
|
|
263
|
+
children
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
] });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/components/progress-bar.tsx
|
|
270
|
+
import { Text as Text4 } from "ink";
|
|
271
|
+
import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
272
|
+
function ProgressBar({ percent, width = 30 }) {
|
|
273
|
+
const theme = useTheme();
|
|
274
|
+
const clamped = Math.max(0, Math.min(1, percent));
|
|
275
|
+
const filled = Math.round(clamped * width);
|
|
276
|
+
return /* @__PURE__ */ jsxs3(Text4, { children: [
|
|
277
|
+
/* @__PURE__ */ jsx5(Text4, { color: theme.primary, children: "\u2588".repeat(filled) }),
|
|
278
|
+
/* @__PURE__ */ jsx5(Text4, { color: theme.gray, dimColor: theme.dimSecondary, children: "\u2591".repeat(width - filled) }),
|
|
279
|
+
/* @__PURE__ */ jsxs3(Text4, { color: theme.primary, children: [
|
|
280
|
+
" ",
|
|
281
|
+
`${Math.round(clamped * 100)}%`.padStart(4)
|
|
282
|
+
] })
|
|
283
|
+
] });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/components/shortcuts.tsx
|
|
287
|
+
import { Text as Text5 } from "ink";
|
|
288
|
+
import { Fragment, jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
289
|
+
function Shortcuts({ items, leading }) {
|
|
290
|
+
const theme = useTheme();
|
|
291
|
+
return /* @__PURE__ */ jsxs4(Text5, { children: [
|
|
292
|
+
leading ? /* @__PURE__ */ jsxs4(Fragment, { children: [
|
|
293
|
+
leading,
|
|
294
|
+
/* @__PURE__ */ jsx6(Text5, { color: theme.gray, dimColor: theme.dimSecondary, children: " \xB7 " })
|
|
295
|
+
] }) : 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: [
|
|
300
|
+
" ",
|
|
301
|
+
label
|
|
302
|
+
] })
|
|
303
|
+
] }, `${key}-${label}`))
|
|
304
|
+
] });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/components/text-input.tsx
|
|
308
|
+
import { useRef as useRef2, useState as useState3 } from "react";
|
|
309
|
+
import { Text as Text6, useInput } from "ink";
|
|
310
|
+
|
|
311
|
+
// src/lib/use-mouse-click.ts
|
|
312
|
+
import { useEffect as useEffect3, useRef } from "react";
|
|
313
|
+
import { useStdin, useStdout as useStdout2 } from "ink";
|
|
314
|
+
var ENABLE = "\x1B[?1000h\x1B[?1006h";
|
|
315
|
+
var DISABLE = "\x1B[?1006l\x1B[?1000l";
|
|
316
|
+
var SGR_PRESS = /\u001B\[<(\d+);(\d+);(\d+)M/g;
|
|
317
|
+
function useMouseClick(onClick, isActive) {
|
|
318
|
+
const handlerRef = useRef(onClick);
|
|
319
|
+
handlerRef.current = onClick;
|
|
320
|
+
const { stdin } = useStdin();
|
|
321
|
+
const { stdout } = useStdout2();
|
|
322
|
+
useEffect3(() => {
|
|
323
|
+
if (!isActive || !stdin || !stdout || !process.stdin.isTTY) return;
|
|
324
|
+
stdout.write(ENABLE);
|
|
325
|
+
const onData = (data) => {
|
|
326
|
+
for (const match of String(data).matchAll(SGR_PRESS)) {
|
|
327
|
+
const [, button, x, y] = match;
|
|
328
|
+
if (button === "0") handlerRef.current(Number(x), Number(y));
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
stdin.on("data", onData);
|
|
332
|
+
return () => {
|
|
333
|
+
stdin.off("data", onData);
|
|
334
|
+
stdout.write(DISABLE);
|
|
335
|
+
};
|
|
336
|
+
}, [isActive, stdin, stdout]);
|
|
337
|
+
}
|
|
338
|
+
var stripMouseReports = (value) => value.replace(/\u001B?\[?<\d+;\d+;\d+[Mm]/g, "");
|
|
339
|
+
|
|
340
|
+
// src/components/text-input.tsx
|
|
341
|
+
import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
342
|
+
var wordLeft = (text, from) => {
|
|
343
|
+
let i = from;
|
|
344
|
+
while (i > 0 && !/\w/.test(text[i - 1])) i--;
|
|
345
|
+
while (i > 0 && /\w/.test(text[i - 1])) i--;
|
|
346
|
+
return i;
|
|
347
|
+
};
|
|
348
|
+
var wordRight = (text, from) => {
|
|
349
|
+
let i = from;
|
|
350
|
+
while (i < text.length && !/\w/.test(text[i])) i++;
|
|
351
|
+
while (i < text.length && /\w/.test(text[i])) i++;
|
|
352
|
+
return i;
|
|
353
|
+
};
|
|
354
|
+
function TextInput({
|
|
355
|
+
value,
|
|
356
|
+
onChange,
|
|
357
|
+
onSubmit,
|
|
358
|
+
placeholder = "",
|
|
359
|
+
width = 40,
|
|
360
|
+
history = [],
|
|
361
|
+
submitOnPaste,
|
|
362
|
+
onTab
|
|
363
|
+
}) {
|
|
364
|
+
const theme = useTheme();
|
|
365
|
+
const [cursorState, setCursorState] = useState3(value.length);
|
|
366
|
+
const [anchorState, setAnchorState] = useState3(null);
|
|
367
|
+
const [historyPos, setHistoryPos] = useState3(null);
|
|
368
|
+
const draftRef = useRef2("");
|
|
369
|
+
const offsetRef = useRef2(0);
|
|
370
|
+
const cursor = Math.min(cursorState, value.length);
|
|
371
|
+
const anchor = anchorState === null ? null : Math.min(anchorState, value.length);
|
|
372
|
+
const selection = anchor !== null && anchor !== cursor ? [Math.min(anchor, cursor), Math.max(anchor, cursor)] : null;
|
|
373
|
+
const place = (position, selecting = false) => {
|
|
374
|
+
if (selecting) {
|
|
375
|
+
if (anchor === null) setAnchorState(cursor);
|
|
376
|
+
} else {
|
|
377
|
+
setAnchorState(null);
|
|
378
|
+
}
|
|
379
|
+
setCursorState(Math.max(0, Math.min(value.length, position)));
|
|
380
|
+
};
|
|
381
|
+
const edit = (next, position) => {
|
|
382
|
+
setAnchorState(null);
|
|
383
|
+
setCursorState(Math.max(0, Math.min(next.length, position)));
|
|
384
|
+
setHistoryPos(null);
|
|
385
|
+
onChange(next);
|
|
386
|
+
};
|
|
387
|
+
const recall = (text) => {
|
|
388
|
+
setAnchorState(null);
|
|
389
|
+
setCursorState(text.length);
|
|
390
|
+
onChange(text);
|
|
391
|
+
};
|
|
392
|
+
const removeRange = (start, end) => edit(value.slice(0, start) + value.slice(end), start);
|
|
393
|
+
useInput((input, key) => {
|
|
394
|
+
if (key.return) {
|
|
395
|
+
onSubmit?.(value);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (key.tab) {
|
|
399
|
+
onTab?.();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (key.pageUp || key.pageDown) return;
|
|
403
|
+
if (key.escape) {
|
|
404
|
+
setAnchorState(null);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (key.upArrow || key.downArrow) {
|
|
408
|
+
if (history.length === 0) return;
|
|
409
|
+
if (key.upArrow) {
|
|
410
|
+
if (historyPos === null) draftRef.current = value;
|
|
411
|
+
const next2 = historyPos === null ? 0 : Math.min(historyPos + 1, history.length - 1);
|
|
412
|
+
if (next2 === historyPos) return;
|
|
413
|
+
setHistoryPos(next2);
|
|
414
|
+
recall(history[next2]);
|
|
415
|
+
} else if (historyPos !== null) {
|
|
416
|
+
const next2 = historyPos - 1;
|
|
417
|
+
setHistoryPos(next2 < 0 ? null : next2);
|
|
418
|
+
recall(next2 < 0 ? draftRef.current : history[next2]);
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (key.home) return place(0);
|
|
423
|
+
if (key.end) return place(value.length);
|
|
424
|
+
if (key.leftArrow || key.rightArrow) {
|
|
425
|
+
const dir = key.leftArrow ? -1 : 1;
|
|
426
|
+
if (selection && !key.shift) return place(dir < 0 ? selection[0] : selection[1]);
|
|
427
|
+
const byWord = key.meta || key.ctrl;
|
|
428
|
+
const target = byWord ? dir < 0 ? wordLeft(value, cursor) : wordRight(value, cursor) : cursor + dir;
|
|
429
|
+
return place(target, key.shift);
|
|
430
|
+
}
|
|
431
|
+
if (key.backspace) {
|
|
432
|
+
if (selection) return removeRange(selection[0], selection[1]);
|
|
433
|
+
return removeRange(key.meta ? wordLeft(value, cursor) : Math.max(0, cursor - 1), cursor);
|
|
434
|
+
}
|
|
435
|
+
if (key.delete) {
|
|
436
|
+
if (selection) return removeRange(selection[0], selection[1]);
|
|
437
|
+
return removeRange(cursor, key.meta ? wordRight(value, cursor) : Math.min(value.length, cursor + 1));
|
|
438
|
+
}
|
|
439
|
+
if (key.ctrl) {
|
|
440
|
+
if (input === "a") return place(0);
|
|
441
|
+
if (input === "e") return place(value.length);
|
|
442
|
+
if (input === "u") return removeRange(0, selection ? selection[1] : cursor);
|
|
443
|
+
if (input === "k") return removeRange(selection ? selection[0] : cursor, value.length);
|
|
444
|
+
if (input === "w") {
|
|
445
|
+
const end2 = selection ? selection[1] : cursor;
|
|
446
|
+
return removeRange(wordLeft(value, selection ? selection[0] : cursor), end2);
|
|
447
|
+
}
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (key.meta) {
|
|
451
|
+
if (input === "b") return place(wordLeft(value, cursor));
|
|
452
|
+
if (input === "f") return place(wordRight(value, cursor));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (!input) return;
|
|
456
|
+
const clean = stripMouseReports(input).replace(/[\x00-\x1f\x7f]/g, "");
|
|
457
|
+
if (!clean) return;
|
|
458
|
+
const [start, end] = selection ?? [cursor, cursor];
|
|
459
|
+
const next = value.slice(0, start) + clean + value.slice(end);
|
|
460
|
+
edit(next, start + clean.length);
|
|
461
|
+
if (clean.length > 1 && value === "" && submitOnPaste?.(next.trim())) onSubmit?.(next);
|
|
462
|
+
});
|
|
463
|
+
const span = Math.max(8, width);
|
|
464
|
+
let offset = Math.min(offsetRef.current, Math.max(0, value.length + 1 - span));
|
|
465
|
+
if (cursor < offset) offset = cursor;
|
|
466
|
+
if (cursor > offset + span - 1) offset = cursor - span + 1;
|
|
467
|
+
offsetRef.current = offset;
|
|
468
|
+
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) })
|
|
472
|
+
] });
|
|
473
|
+
}
|
|
474
|
+
const cells = Array.from({ length: Math.min(span, value.length - offset + 1) }, (_, column) => {
|
|
475
|
+
const index = offset + column;
|
|
476
|
+
const selected = selection !== null && index >= selection[0] && index < selection[1];
|
|
477
|
+
const atCursor = selection === null && index === cursor;
|
|
478
|
+
return /* @__PURE__ */ jsx7(Text6, { color: theme.primary, inverse: selected || atCursor, children: value[index] ?? " " }, index);
|
|
479
|
+
});
|
|
480
|
+
return /* @__PURE__ */ jsx7(Text6, { children: cells });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/lib/click-map.ts
|
|
484
|
+
var ANSI_PATTERN = new RegExp(
|
|
485
|
+
[
|
|
486
|
+
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
|
|
487
|
+
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))"
|
|
488
|
+
].join("|"),
|
|
489
|
+
"g"
|
|
490
|
+
);
|
|
491
|
+
var stripAnsi = (text) => text.replace(ANSI_PATTERN, "");
|
|
492
|
+
var frameLines = [];
|
|
493
|
+
function captureFrames(stream) {
|
|
494
|
+
return new Proxy(stream, {
|
|
495
|
+
get(target, prop) {
|
|
496
|
+
if (prop === "write") {
|
|
497
|
+
return (chunk, ...rest) => {
|
|
498
|
+
const lines = String(chunk).split("\n").map(stripAnsi);
|
|
499
|
+
if (lines.some((line) => line.trim() !== "")) frameLines = lines;
|
|
500
|
+
return target.write(chunk, ...rest);
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
const value = Reflect.get(target, prop);
|
|
504
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
function clickTargetAt(x, y, targets) {
|
|
509
|
+
for (const target of targets) {
|
|
510
|
+
const { match, padX = 1, padY = 0 } = target;
|
|
511
|
+
for (let row = y - 1 - padY; row <= y - 1 + padY; row++) {
|
|
512
|
+
const line = frameLines[row];
|
|
513
|
+
if (!line) continue;
|
|
514
|
+
let index = line.indexOf(match);
|
|
515
|
+
while (index !== -1) {
|
|
516
|
+
if (x - 1 >= index - padX && x - 1 <= index + match.length - 1 + padX) return target;
|
|
517
|
+
index = line.indexOf(match, index + 1);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return void 0;
|
|
522
|
+
}
|
|
523
|
+
function findFrameRow(text) {
|
|
524
|
+
return frameLines.findIndex((line) => line.includes(text));
|
|
525
|
+
}
|
|
526
|
+
function frameRowSpan(row) {
|
|
527
|
+
const line = frameLines[row];
|
|
528
|
+
if (!line) return void 0;
|
|
529
|
+
const first = line.search(/\S/);
|
|
530
|
+
if (first === -1) return void 0;
|
|
531
|
+
return [first + 1, line.trimEnd().length];
|
|
532
|
+
}
|
|
533
|
+
|
|
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
|
+
// src/lib/history.ts
|
|
589
|
+
import fs from "fs";
|
|
590
|
+
import os from "os";
|
|
591
|
+
import path from "path";
|
|
592
|
+
var HISTORY_FILE = path.join(os.homedir(), ".config", "yeet", "history.json");
|
|
593
|
+
var LIMIT = 50;
|
|
594
|
+
function loadHistory() {
|
|
595
|
+
try {
|
|
596
|
+
const parsed = JSON.parse(fs.readFileSync(HISTORY_FILE, "utf8"));
|
|
597
|
+
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
|
|
598
|
+
} catch {
|
|
599
|
+
return [];
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
function addToHistory(url) {
|
|
603
|
+
const next = [url, ...loadHistory().filter((entry) => entry !== url)].slice(0, LIMIT);
|
|
604
|
+
try {
|
|
605
|
+
fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
|
|
606
|
+
fs.writeFileSync(HISTORY_FILE, `${JSON.stringify(next, null, 2)}
|
|
607
|
+
`);
|
|
608
|
+
} catch {
|
|
609
|
+
}
|
|
610
|
+
return next;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/lib/platforms.ts
|
|
614
|
+
var PLATFORMS = [
|
|
615
|
+
{ hosts: ["youtube.com", "youtu.be", "music.youtube.com"], platform: { key: "youtube", label: "YouTube" } },
|
|
616
|
+
{ hosts: ["x.com", "twitter.com"], platform: { key: "x", label: "X / Twitter" } },
|
|
617
|
+
{ hosts: ["instagram.com"], platform: { key: "instagram", label: "Instagram" } },
|
|
618
|
+
{ hosts: ["threads.net", "threads.com"], platform: { key: "threads", label: "Threads" } },
|
|
619
|
+
{ hosts: ["tiktok.com"], platform: { key: "tiktok", label: "TikTok" } },
|
|
620
|
+
{ hosts: ["vimeo.com"], platform: { key: "vimeo", label: "Vimeo" } },
|
|
621
|
+
{ hosts: ["twitch.tv"], platform: { key: "twitch", label: "Twitch" } },
|
|
622
|
+
{ hosts: ["reddit.com"], platform: { key: "reddit", label: "Reddit" } },
|
|
623
|
+
{ hosts: ["facebook.com", "fb.watch"], platform: { key: "facebook", label: "Facebook" } }
|
|
624
|
+
];
|
|
625
|
+
function detectPlatform(url) {
|
|
626
|
+
let hostname;
|
|
627
|
+
try {
|
|
628
|
+
hostname = new URL(url).hostname.toLowerCase();
|
|
629
|
+
} catch {
|
|
630
|
+
return { key: "unknown", label: "Unknown site" };
|
|
631
|
+
}
|
|
632
|
+
for (const { hosts, platform } of PLATFORMS) {
|
|
633
|
+
if (hosts.some((h) => hostname === h || hostname.endsWith(`.${h}`))) {
|
|
634
|
+
return platform;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return { key: "generic", label: hostname };
|
|
638
|
+
}
|
|
639
|
+
function isProbablyUrl(input) {
|
|
640
|
+
try {
|
|
641
|
+
const u = new URL(input.trim());
|
|
642
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
643
|
+
} catch {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// src/lib/ytdlp.ts
|
|
649
|
+
import { spawn } from "child_process";
|
|
650
|
+
import { createWriteStream } from "fs";
|
|
651
|
+
import fs2 from "fs/promises";
|
|
652
|
+
import os2 from "os";
|
|
653
|
+
import path2 from "path";
|
|
654
|
+
import { Readable } from "stream";
|
|
655
|
+
import { pipeline } from "stream/promises";
|
|
656
|
+
var YEET_DIR = path2.join(os2.homedir(), ".yeet", "bin");
|
|
657
|
+
var RELEASE_BASE = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
|
|
658
|
+
function ytDlpAssetName() {
|
|
659
|
+
if (process.platform === "win32") return "yt-dlp.exe";
|
|
660
|
+
if (process.platform === "darwin") return "yt-dlp_macos";
|
|
661
|
+
return process.arch === "arm64" ? "yt-dlp_linux_aarch64" : "yt-dlp_linux";
|
|
662
|
+
}
|
|
663
|
+
function commandWorks(cmd, args2) {
|
|
664
|
+
return new Promise((resolve) => {
|
|
665
|
+
let child;
|
|
666
|
+
try {
|
|
667
|
+
child = spawn(cmd, args2, { stdio: "ignore", timeout: 1e4 });
|
|
668
|
+
} catch {
|
|
669
|
+
resolve(false);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
child.on("error", () => resolve(false));
|
|
673
|
+
child.on("close", (code) => resolve(code === 0));
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
async function ensureYtDlp(onStatus, signal) {
|
|
677
|
+
if (await commandWorks("yt-dlp", ["--version"])) return "yt-dlp";
|
|
678
|
+
const local = path2.join(YEET_DIR, process.platform === "win32" ? "yt-dlp.exe" : "yt-dlp");
|
|
679
|
+
if (await commandWorks(local, ["--version"])) return local;
|
|
680
|
+
onStatus("first run: fetching yt-dlp\u2026");
|
|
681
|
+
await fs2.mkdir(YEET_DIR, { recursive: true });
|
|
682
|
+
const url = `${RELEASE_BASE}/${ytDlpAssetName()}`;
|
|
683
|
+
const response = await fetch(url, { signal });
|
|
684
|
+
if (!response.ok || !response.body) {
|
|
685
|
+
throw new Error(`Could not download yt-dlp (${response.status}). Check your connection and try again.`);
|
|
686
|
+
}
|
|
687
|
+
const tmp = `${local}.download`;
|
|
688
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(tmp), { signal });
|
|
689
|
+
await fs2.chmod(tmp, 493);
|
|
690
|
+
await fs2.rename(tmp, local);
|
|
691
|
+
return local;
|
|
692
|
+
}
|
|
693
|
+
async function findFfmpeg() {
|
|
694
|
+
if (await commandWorks("ffmpeg", ["-version"])) return void 0;
|
|
695
|
+
try {
|
|
696
|
+
const mod = await import("ffmpeg-static");
|
|
697
|
+
const ffmpegPath = mod.default ?? mod;
|
|
698
|
+
if (ffmpegPath && await commandWorks(ffmpegPath, ["-version"])) return ffmpegPath;
|
|
699
|
+
} catch {
|
|
700
|
+
}
|
|
701
|
+
return void 0;
|
|
702
|
+
}
|
|
703
|
+
async function probe(ytdlp, url, signal) {
|
|
704
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
705
|
+
const child = spawn(ytdlp, ["-J", "--no-playlist", "--no-warnings", url], { signal });
|
|
706
|
+
let out = "";
|
|
707
|
+
let stderr = "";
|
|
708
|
+
child.stdout.on("data", (chunk) => out += chunk);
|
|
709
|
+
child.stderr.on("data", (chunk) => stderr += chunk);
|
|
710
|
+
child.on("error", reject);
|
|
711
|
+
child.on("close", (code) => {
|
|
712
|
+
if (code !== 0) {
|
|
713
|
+
reject(new Error(cleanYtDlpError(stderr) || `yt-dlp exited with code ${code}`));
|
|
714
|
+
} else {
|
|
715
|
+
resolve(out);
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
let info;
|
|
720
|
+
try {
|
|
721
|
+
info = JSON.parse(stdout);
|
|
722
|
+
} catch {
|
|
723
|
+
throw new Error("Could not parse video info from yt-dlp.");
|
|
724
|
+
}
|
|
725
|
+
const infoJsonPath = path2.join(os2.tmpdir(), `yeet-info-${process.pid}-${Date.now()}.json`);
|
|
726
|
+
await fs2.writeFile(infoJsonPath, stdout);
|
|
727
|
+
return { info, infoJsonPath };
|
|
728
|
+
}
|
|
729
|
+
var MAX_VIDEO_CHOICES = 8;
|
|
730
|
+
function buildChoices(info) {
|
|
731
|
+
const formats = info.formats ?? [];
|
|
732
|
+
const choices = [];
|
|
733
|
+
const audioOnly = formats.filter((f) => f.acodec && f.acodec !== "none" && (!f.vcodec || f.vcodec === "none"));
|
|
734
|
+
const bestAudio = [...audioOnly].sort((a, b) => (b.abr ?? b.tbr ?? 0) - (a.abr ?? a.tbr ?? 0))[0];
|
|
735
|
+
const audioSize = bestAudio?.filesize ?? bestAudio?.filesize_approx;
|
|
736
|
+
const videos = formats.filter((f) => f.vcodec && f.vcodec !== "none" && f.height);
|
|
737
|
+
const heights = [...new Set(videos.map((f) => f.height))].sort((a, b) => b - a);
|
|
738
|
+
for (const height of heights.slice(0, MAX_VIDEO_CHOICES)) {
|
|
739
|
+
const candidates = videos.filter((f) => f.height === height);
|
|
740
|
+
const best = [...candidates].sort((a, b) => scoreVideo(b) - scoreVideo(a))[0];
|
|
741
|
+
const muxed = best.acodec && best.acodec !== "none";
|
|
742
|
+
const size = (best.filesize ?? best.filesize_approx ?? 0) + (muxed ? 0 : audioSize ?? 0);
|
|
743
|
+
const sizeLabel = size > 0 ? ` \xB7 ~${formatBytes(size)}` : "";
|
|
744
|
+
choices.push({
|
|
745
|
+
kind: "video",
|
|
746
|
+
label: `${height}p \xB7 mp4${sizeLabel}`,
|
|
747
|
+
args: [
|
|
748
|
+
"-f",
|
|
749
|
+
`bv*[height=${height}]+ba/b[height=${height}]/bv*[height<=${height}]+ba/b`,
|
|
750
|
+
"--merge-output-format",
|
|
751
|
+
"mp4"
|
|
752
|
+
]
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
if (choices.length === 0) {
|
|
756
|
+
choices.push({
|
|
757
|
+
kind: "video",
|
|
758
|
+
label: "best available \xB7 mp4",
|
|
759
|
+
args: ["-f", "bv*+ba/b", "--merge-output-format", "mp4"]
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
const audioSizeLabel = audioSize ? ` \xB7 ~${formatBytes(audioSize)}` : "";
|
|
763
|
+
choices.push({
|
|
764
|
+
kind: "audio",
|
|
765
|
+
label: `audio only \xB7 mp3${audioSizeLabel}`,
|
|
766
|
+
args: ["-f", "ba/b", "-x", "--audio-format", "mp3", "--audio-quality", "0"]
|
|
767
|
+
});
|
|
768
|
+
return choices;
|
|
769
|
+
}
|
|
770
|
+
function scoreVideo(f) {
|
|
771
|
+
let score = f.tbr ?? 0;
|
|
772
|
+
if (f.ext === "mp4") score += 1e4;
|
|
773
|
+
if (f.vcodec?.startsWith("avc")) score += 5e3;
|
|
774
|
+
return score;
|
|
775
|
+
}
|
|
776
|
+
var PROGRESS_PREFIX = "YEET|";
|
|
777
|
+
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
|
+
var activeChild;
|
|
779
|
+
process.on("exit", () => activeChild?.kill("SIGTERM"));
|
|
780
|
+
function download(opts, handlers, signal) {
|
|
781
|
+
const args2 = [
|
|
782
|
+
...opts.infoJsonPath ? ["--load-info-json", opts.infoJsonPath] : [opts.url],
|
|
783
|
+
...opts.choice.args,
|
|
784
|
+
"--no-playlist",
|
|
785
|
+
"--no-warnings",
|
|
786
|
+
"--newline",
|
|
787
|
+
// --print implies --quiet, which suppresses progress bars and the
|
|
788
|
+
// [Merger]/[ExtractAudio] lines we detect the processing phase from
|
|
789
|
+
"--no-quiet",
|
|
790
|
+
"--progress",
|
|
791
|
+
"--progress-template",
|
|
792
|
+
`download:${PROGRESS_TEMPLATE}`,
|
|
793
|
+
"--print",
|
|
794
|
+
"after_move:filepath",
|
|
795
|
+
"--no-simulate",
|
|
796
|
+
"-o",
|
|
797
|
+
path2.join(opts.outDir, "%(title).60s.%(ext)s")
|
|
798
|
+
];
|
|
799
|
+
if (opts.ffmpegLocation) args2.push("--ffmpeg-location", opts.ffmpegLocation);
|
|
800
|
+
return new Promise((resolve, reject) => {
|
|
801
|
+
const child = spawn(opts.ytdlp, args2, { signal });
|
|
802
|
+
activeChild = child;
|
|
803
|
+
let stderr = "";
|
|
804
|
+
let filepath = "";
|
|
805
|
+
let part = 0;
|
|
806
|
+
let totalParts = 1;
|
|
807
|
+
let lastDownloaded = 0;
|
|
808
|
+
let buffer = "";
|
|
809
|
+
const destinations = [];
|
|
810
|
+
child.stdout.on("data", (chunk) => {
|
|
811
|
+
buffer += chunk.toString();
|
|
812
|
+
const lines = buffer.split("\n");
|
|
813
|
+
buffer = lines.pop() ?? "";
|
|
814
|
+
for (const rawLine of lines) {
|
|
815
|
+
const line = rawLine.trim();
|
|
816
|
+
if (!line) continue;
|
|
817
|
+
if (line.startsWith(PROGRESS_PREFIX)) {
|
|
818
|
+
const [downloaded, total, totalEstimate, speed, eta] = line.slice(PROGRESS_PREFIX.length).split("|");
|
|
819
|
+
const downloadedBytes = toNumber(downloaded) ?? 0;
|
|
820
|
+
if (downloadedBytes < lastDownloaded) part++;
|
|
821
|
+
lastDownloaded = downloadedBytes;
|
|
822
|
+
handlers.onProgress({
|
|
823
|
+
downloadedBytes,
|
|
824
|
+
totalBytes: toNumber(total) ?? toNumber(totalEstimate),
|
|
825
|
+
speed: toNumber(speed),
|
|
826
|
+
eta: toNumber(eta),
|
|
827
|
+
part,
|
|
828
|
+
totalParts
|
|
829
|
+
});
|
|
830
|
+
} else if (line.includes("Downloading 1 format(s):")) {
|
|
831
|
+
totalParts = (line.split("format(s):")[1] ?? "").trim().split("+").length;
|
|
832
|
+
} else if (line.includes("[Merger]") || line.includes("[ExtractAudio]")) {
|
|
833
|
+
const merging = /^\[Merger\] Merging formats into "(.+)"$/.exec(line)?.[1];
|
|
834
|
+
const extracting = /^\[ExtractAudio\] Destination: (.+)$/.exec(line)?.[1];
|
|
835
|
+
const target = merging ?? extracting;
|
|
836
|
+
if (target) destinations.push(target);
|
|
837
|
+
handlers.onProcessing();
|
|
838
|
+
} else if (line.startsWith("[download] Destination: ")) {
|
|
839
|
+
destinations.push(line.slice("[download] Destination: ".length));
|
|
840
|
+
} else if (path2.isAbsolute(line)) {
|
|
841
|
+
filepath = line;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
child.stderr.on("data", (chunk) => stderr += chunk);
|
|
846
|
+
child.on("error", reject);
|
|
847
|
+
child.on("close", (code) => {
|
|
848
|
+
activeChild = void 0;
|
|
849
|
+
if (signal?.aborted) {
|
|
850
|
+
void removePartials(destinations);
|
|
851
|
+
reject(new Error("Download cancelled."));
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (code === 0 && filepath) {
|
|
855
|
+
resolve(filepath);
|
|
856
|
+
} else {
|
|
857
|
+
reject(new Error(cleanYtDlpError(stderr) || `Download failed (yt-dlp exit code ${code}).`));
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
function removePartials(destinations) {
|
|
863
|
+
return Promise.allSettled(
|
|
864
|
+
destinations.flatMap((dest) => [dest, `${dest}.part`, `${dest}.ytdl`]).map((file) => fs2.rm(file, { force: true }))
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
function toNumber(value) {
|
|
868
|
+
if (!value || value === "NA" || value === "None") return void 0;
|
|
869
|
+
const n = Number.parseFloat(value);
|
|
870
|
+
return Number.isFinite(n) ? n : void 0;
|
|
871
|
+
}
|
|
872
|
+
function cleanYtDlpError(stderr) {
|
|
873
|
+
const lines = stderr.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("ERROR:"));
|
|
874
|
+
const last = lines.at(-1);
|
|
875
|
+
return last ? last.replace(/^ERROR:\s*(\[[^\]]+\]\s*)?/, "") : "";
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// src/app.tsx
|
|
879
|
+
import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
880
|
+
var OUT_DIR = path3.join(os3.homedir(), "Downloads");
|
|
881
|
+
var YEET_BUTTON = "yeet";
|
|
882
|
+
var DONE_LABEL = "\u21B5 yeet another";
|
|
883
|
+
var TAGLINE = "yeet any video. paste. yeet. done.";
|
|
884
|
+
var choiceLabel = (choice) => `${choice.kind === "audio" ? "\u266A " : "\u25B6 "}${choice.label}`;
|
|
885
|
+
function ChoiceIndicator({ isSelected }) {
|
|
886
|
+
const theme = useTheme();
|
|
887
|
+
return /* @__PURE__ */ jsx8(Box5, { marginRight: 1, children: /* @__PURE__ */ jsx8(Text7, { color: theme.primary, children: isSelected ? "\u276F" : " " }) });
|
|
888
|
+
}
|
|
889
|
+
function ChoiceItem({ isSelected, label }) {
|
|
890
|
+
const theme = useTheme();
|
|
891
|
+
return /* @__PURE__ */ jsx8(Text7, { color: theme.primary, bold: isSelected, children: label });
|
|
892
|
+
}
|
|
893
|
+
var Gap = ({ lines = 1 }) => /* @__PURE__ */ jsx8(Box5, { flexDirection: "column", flexShrink: 0, children: Array.from({ length: lines }, (_, i) => /* @__PURE__ */ jsx8(Text7, { children: " " }, i)) });
|
|
894
|
+
function partLabel(progress) {
|
|
895
|
+
return progress.totalParts > 1 ? `part ${progress.part + 1}/${progress.totalParts} ` : "";
|
|
896
|
+
}
|
|
897
|
+
function downloadMeta(progress) {
|
|
898
|
+
const speed = progress.speed ? formatSpeed(progress.speed) : "";
|
|
899
|
+
const eta = progress.eta ? `${formatEta(progress.eta)} left` : "";
|
|
900
|
+
return `${partLabel(progress)}${speed.padStart(10)} ${eta.padEnd(12)}`;
|
|
901
|
+
}
|
|
902
|
+
function indeterminateMeta(progress) {
|
|
903
|
+
const bytes = formatBytes(progress.downloadedBytes);
|
|
904
|
+
const speed = progress.speed ? formatSpeed(progress.speed) : "";
|
|
905
|
+
return `${partLabel(progress)}${bytes.padStart(8)} ${speed.padEnd(10)}`;
|
|
906
|
+
}
|
|
907
|
+
var HINTS = {
|
|
908
|
+
input: [
|
|
909
|
+
["\u21B5", "yeet"],
|
|
910
|
+
["^c", "quit"]
|
|
911
|
+
],
|
|
912
|
+
probing: [
|
|
913
|
+
["esc", "cancel"],
|
|
914
|
+
["^c", "quit"]
|
|
915
|
+
],
|
|
916
|
+
picking: [
|
|
917
|
+
["\u2191\u2193", "choose"],
|
|
918
|
+
["\u21B5", "yeet"],
|
|
919
|
+
["esc", "back"],
|
|
920
|
+
["^c", "quit"]
|
|
921
|
+
],
|
|
922
|
+
downloading: [
|
|
923
|
+
["esc", "cancel"],
|
|
924
|
+
["^c", "quit"]
|
|
925
|
+
],
|
|
926
|
+
done: [["^c", "quit"]],
|
|
927
|
+
error: [
|
|
928
|
+
["\u21B5", "try again"],
|
|
929
|
+
["^c", "quit"]
|
|
930
|
+
]
|
|
931
|
+
};
|
|
932
|
+
function App({ initialThemeMode: initialThemeMode2 = "auto", ...props }) {
|
|
933
|
+
const [themeMode, setThemeMode] = useState4(initialThemeMode2);
|
|
934
|
+
const cycleTheme = useCallback(() => {
|
|
935
|
+
setThemeMode(nextThemeMode);
|
|
936
|
+
}, []);
|
|
937
|
+
return /* @__PURE__ */ jsx8(ThemeProvider, { mode: themeMode, children: /* @__PURE__ */ jsx8(AppContent, { ...props, cycleTheme }) });
|
|
938
|
+
}
|
|
939
|
+
function AppContent({
|
|
940
|
+
initialUrl: initialUrl2,
|
|
941
|
+
clipboardUrl: clipboardUrl2,
|
|
942
|
+
onOutcome,
|
|
943
|
+
cycleTheme
|
|
944
|
+
}) {
|
|
945
|
+
const theme = useTheme();
|
|
946
|
+
const { exit } = useApp();
|
|
947
|
+
const { stdout } = useStdout3();
|
|
948
|
+
const [url, setUrl] = useState4(initialUrl2 ?? "");
|
|
949
|
+
const [urlInput, setUrlInput] = useState4("");
|
|
950
|
+
const [history, setHistory] = useState4(loadHistory);
|
|
951
|
+
const [platform, setPlatform] = useState4();
|
|
952
|
+
const [info, setInfo] = useState4();
|
|
953
|
+
const [choices, setChoices] = useState4([]);
|
|
954
|
+
const ytdlpRef = useRef3("");
|
|
955
|
+
const highlightRef = useRef3(0);
|
|
956
|
+
const infoJsonRef = useRef3(void 0);
|
|
957
|
+
const abortRef = useRef3(void 0);
|
|
958
|
+
const [phase, setPhase] = useState4(initialUrl2 ? { name: "probing", status: "warming up\u2026" } : { name: "input" });
|
|
959
|
+
const columns = stdout?.columns && stdout.columns > 0 ? stdout.columns : 80;
|
|
960
|
+
const boxWidth = Math.max(14, Math.min(64, columns - 6));
|
|
961
|
+
const contentWidth = Math.max(10, Math.min(columns - 4, 78));
|
|
962
|
+
const startProbe = useCallback(async (targetUrl) => {
|
|
963
|
+
const controller = new AbortController();
|
|
964
|
+
abortRef.current = controller;
|
|
965
|
+
setPlatform(detectPlatform(targetUrl));
|
|
966
|
+
setPhase({ name: "probing", status: "warming up\u2026" });
|
|
967
|
+
try {
|
|
968
|
+
const ytdlp = ytdlpRef.current || await ensureYtDlp((status) => setPhase({ name: "probing", status }), controller.signal);
|
|
969
|
+
ytdlpRef.current = ytdlp;
|
|
970
|
+
if (controller.signal.aborted) return;
|
|
971
|
+
setPhase({ name: "probing", status: "fetching video info\u2026" });
|
|
972
|
+
const { info: videoInfo, infoJsonPath } = await probe(ytdlp, targetUrl, controller.signal);
|
|
973
|
+
if (controller.signal.aborted) return;
|
|
974
|
+
infoJsonRef.current = infoJsonPath;
|
|
975
|
+
setInfo(videoInfo);
|
|
976
|
+
setChoices(buildChoices(videoInfo));
|
|
977
|
+
highlightRef.current = 0;
|
|
978
|
+
setPhase({ name: "picking" });
|
|
979
|
+
} catch (error) {
|
|
980
|
+
if (controller.signal.aborted) return;
|
|
981
|
+
setPhase({ name: "error", message: error instanceof Error ? error.message : String(error) });
|
|
982
|
+
}
|
|
983
|
+
}, []);
|
|
984
|
+
useEffect4(() => {
|
|
985
|
+
if (initialUrl2) void startProbe(initialUrl2);
|
|
986
|
+
}, [initialUrl2, startProbe]);
|
|
987
|
+
const resetToInput = useCallback(() => {
|
|
988
|
+
setUrl("");
|
|
989
|
+
setUrlInput("");
|
|
990
|
+
setPlatform(void 0);
|
|
991
|
+
setInfo(void 0);
|
|
992
|
+
setChoices([]);
|
|
993
|
+
setPhase({ name: "input" });
|
|
994
|
+
}, []);
|
|
995
|
+
const cancelRun = useCallback(() => {
|
|
996
|
+
abortRef.current?.abort();
|
|
997
|
+
resetToInput();
|
|
998
|
+
setUrlInput(url);
|
|
999
|
+
}, [resetToInput, url]);
|
|
1000
|
+
useInput2(
|
|
1001
|
+
(input, key) => {
|
|
1002
|
+
if (key.ctrl && input === "t") {
|
|
1003
|
+
cycleTheme();
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
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();
|
|
1009
|
+
},
|
|
1010
|
+
{ isActive: Boolean(process.stdin.isTTY) }
|
|
1011
|
+
);
|
|
1012
|
+
const handleUrlSubmit = (value) => {
|
|
1013
|
+
const trimmed = value.trim();
|
|
1014
|
+
if (!isProbablyUrl(trimmed)) {
|
|
1015
|
+
setPhase({ name: "input", warning: "that doesn\u2019t look like a link \u2014 paste a full url" });
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
setUrl(trimmed);
|
|
1019
|
+
void startProbe(trimmed);
|
|
1020
|
+
};
|
|
1021
|
+
const clipboardOffered = Boolean(clipboardUrl2) && urlInput === "";
|
|
1022
|
+
const clipboardAccepted = Boolean(clipboardUrl2) && urlInput === clipboardUrl2;
|
|
1023
|
+
const handlePick = (item) => {
|
|
1024
|
+
const choice = choices[item.value];
|
|
1025
|
+
const controller = new AbortController();
|
|
1026
|
+
abortRef.current = controller;
|
|
1027
|
+
setPhase({ name: "downloading", choice, processing: false });
|
|
1028
|
+
void (async () => {
|
|
1029
|
+
const handlers = {
|
|
1030
|
+
onProgress: (progress) => setPhase((prev) => prev.name === "downloading" ? { ...prev, progress, processing: false } : prev),
|
|
1031
|
+
onProcessing: () => setPhase((prev) => prev.name === "downloading" ? { ...prev, processing: true } : prev)
|
|
1032
|
+
};
|
|
1033
|
+
try {
|
|
1034
|
+
const ffmpegLocation = await findFfmpeg();
|
|
1035
|
+
const base = { ytdlp: ytdlpRef.current, ffmpegLocation, url, choice, outDir: OUT_DIR };
|
|
1036
|
+
let filepath;
|
|
1037
|
+
try {
|
|
1038
|
+
filepath = await download({ ...base, infoJsonPath: infoJsonRef.current }, handlers, controller.signal);
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
if (controller.signal.aborted) throw error;
|
|
1041
|
+
setPhase(
|
|
1042
|
+
(prev) => prev.name === "downloading" ? { ...prev, progress: void 0, refreshing: true } : prev
|
|
1043
|
+
);
|
|
1044
|
+
filepath = await download(base, handlers, controller.signal);
|
|
1045
|
+
}
|
|
1046
|
+
onOutcome({ filepath });
|
|
1047
|
+
setHistory(addToHistory(url));
|
|
1048
|
+
setPhase({ name: "done", filepath });
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
if (controller.signal.aborted) return;
|
|
1051
|
+
setPhase({ name: "error", message: error instanceof Error ? error.message : String(error) });
|
|
1052
|
+
}
|
|
1053
|
+
})();
|
|
1054
|
+
};
|
|
1055
|
+
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)];
|
|
1058
|
+
}
|
|
1059
|
+
const hintAction = (key) => {
|
|
1060
|
+
if (key === "^c") return () => exit();
|
|
1061
|
+
if (key === "^t") return cycleTheme;
|
|
1062
|
+
if (key === "esc") return phase.name === "probing" || phase.name === "downloading" ? cancelRun : resetToInput;
|
|
1063
|
+
if (key === "\u21B5") {
|
|
1064
|
+
if (phase.name === "input") return () => handleUrlSubmit(urlInput);
|
|
1065
|
+
if (phase.name === "picking") return () => handlePick({ value: highlightRef.current });
|
|
1066
|
+
if (phase.name === "error" || phase.name === "done") return resetToInput;
|
|
1067
|
+
}
|
|
1068
|
+
return void 0;
|
|
1069
|
+
};
|
|
1070
|
+
const clickTargets = [];
|
|
1071
|
+
if (phase.name === "input") {
|
|
1072
|
+
clickTargets.push({ match: ` ${YEET_BUTTON} `, padY: 1, action: () => handleUrlSubmit(urlInput) });
|
|
1073
|
+
}
|
|
1074
|
+
if (phase.name === "picking") {
|
|
1075
|
+
for (const [index, choice] of choices.entries()) {
|
|
1076
|
+
clickTargets.push({ match: choiceLabel(choice), action: () => handlePick({ value: index }) });
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
if (phase.name === "done") {
|
|
1080
|
+
clickTargets.push({ match: DONE_LABEL, padX: 4, padY: 1, action: resetToInput });
|
|
1081
|
+
}
|
|
1082
|
+
for (const [key, label] of hints) {
|
|
1083
|
+
const action = hintAction(key);
|
|
1084
|
+
if (action) clickTargets.push({ match: `${key} ${label}`, action });
|
|
1085
|
+
}
|
|
1086
|
+
useMouseClick(
|
|
1087
|
+
(x, y) => {
|
|
1088
|
+
const taglineRow = findFrameRow(TAGLINE);
|
|
1089
|
+
if (taglineRow > 3 && y - 1 >= taglineRow - 4 && y - 1 <= taglineRow - 2) {
|
|
1090
|
+
const span = frameRowSpan(y - 1);
|
|
1091
|
+
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();
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
clickTargetAt(x, y, clickTargets)?.action();
|
|
1098
|
+
},
|
|
1099
|
+
Boolean(process.stdin.isTTY)
|
|
1100
|
+
);
|
|
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(
|
|
1109
|
+
TextInput,
|
|
1110
|
+
{
|
|
1111
|
+
value: urlInput,
|
|
1112
|
+
onChange: setUrlInput,
|
|
1113
|
+
onSubmit: handleUrlSubmit,
|
|
1114
|
+
placeholder: "https://youtube.com/watch?v=\u2026",
|
|
1115
|
+
width: boxWidth - 6,
|
|
1116
|
+
history,
|
|
1117
|
+
submitOnPaste: isProbablyUrl,
|
|
1118
|
+
onTab: () => {
|
|
1119
|
+
if (clipboardOffered) setUrlInput(clipboardUrl2);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
) }),
|
|
1123
|
+
phase.warning ? /* @__PURE__ */ jsxs6(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: [
|
|
1124
|
+
"\u2717 ",
|
|
1125
|
+
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
|
|
1127
|
+
] }),
|
|
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: [
|
|
1134
|
+
"\u25B8 ",
|
|
1135
|
+
platform.label,
|
|
1136
|
+
info?.duration ? ` \xB7 ${formatDuration(info.duration)}` : "",
|
|
1137
|
+
info?.uploader ? ` \xB7 ${info.uploader}` : ""
|
|
1138
|
+
] })
|
|
1139
|
+
] }),
|
|
1140
|
+
/* @__PURE__ */ jsx8(Panel, { title: "Download", width: 38, children: /* @__PURE__ */ jsx8(
|
|
1141
|
+
SelectInput,
|
|
1142
|
+
{
|
|
1143
|
+
indicatorComponent: ChoiceIndicator,
|
|
1144
|
+
itemComponent: ChoiceItem,
|
|
1145
|
+
items: choices.map((choice, index) => ({
|
|
1146
|
+
key: String(index),
|
|
1147
|
+
label: choiceLabel(choice),
|
|
1148
|
+
value: index
|
|
1149
|
+
})),
|
|
1150
|
+
onSelect: handlePick,
|
|
1151
|
+
onHighlight: (item) => highlightRef.current = item.value
|
|
1152
|
+
}
|
|
1153
|
+
) })
|
|
1154
|
+
] }),
|
|
1155
|
+
phase.name === "downloading" && /* @__PURE__ */ jsxs6(Box5, { flexDirection: "column", alignItems: "center", children: [
|
|
1156
|
+
/* @__PURE__ */ jsxs6(Text7, { color: theme.gray, dimColor: theme.dimSecondary, children: [
|
|
1157
|
+
info?.title ? `${truncate(info.title, 42)} \xB7 ` : "",
|
|
1158
|
+
phase.choice.label
|
|
1159
|
+
] }),
|
|
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
|
+
] })
|
|
1186
|
+
] })
|
|
1187
|
+
] }),
|
|
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:" })
|
|
1192
|
+
] }),
|
|
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,
|
|
1197
|
+
{
|
|
1198
|
+
borderStyle: "round",
|
|
1199
|
+
borderColor: theme.gray,
|
|
1200
|
+
borderDimColor: theme.dimSecondary,
|
|
1201
|
+
borderBackgroundColor: theme.background,
|
|
1202
|
+
paddingX: 3,
|
|
1203
|
+
children: /* @__PURE__ */ jsx8(Text7, { bold: true, color: theme.primary, children: DONE_LABEL })
|
|
1204
|
+
}
|
|
1205
|
+
)
|
|
1206
|
+
] }),
|
|
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: [
|
|
1208
|
+
"\u2717 ",
|
|
1209
|
+
phase.message
|
|
1210
|
+
] }) }),
|
|
1211
|
+
hints.length > 0 ? /* @__PURE__ */ jsxs6(Fragment2, { children: [
|
|
1212
|
+
/* @__PURE__ */ jsx8(Gap, { lines: 2 }),
|
|
1213
|
+
/* @__PURE__ */ jsx8(
|
|
1214
|
+
Shortcuts,
|
|
1215
|
+
{
|
|
1216
|
+
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: [
|
|
1220
|
+
" ",
|
|
1221
|
+
phase.status
|
|
1222
|
+
] })
|
|
1223
|
+
] }) : void 0
|
|
1224
|
+
}
|
|
1225
|
+
)
|
|
1226
|
+
] }) : null
|
|
1227
|
+
] });
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// src/lib/args.ts
|
|
1231
|
+
function parseArgs(args2) {
|
|
1232
|
+
const result = { help: false, version: false };
|
|
1233
|
+
const positional = [];
|
|
1234
|
+
for (let index = 0; index < args2.length; index++) {
|
|
1235
|
+
const arg = args2[index];
|
|
1236
|
+
if (arg === "-h" || arg === "--help") {
|
|
1237
|
+
result.help = true;
|
|
1238
|
+
} else if (arg === "-v" || arg === "--version") {
|
|
1239
|
+
result.version = true;
|
|
1240
|
+
} else if (arg === "--theme") {
|
|
1241
|
+
const value = args2[++index];
|
|
1242
|
+
if (!value) return { ...result, error: "--theme needs a value: auto, light, or dark" };
|
|
1243
|
+
if (!isThemeMode(value)) return { ...result, error: `unknown theme \u201C${value}\u201D \u2014 use auto, light, or dark` };
|
|
1244
|
+
result.themeMode = value;
|
|
1245
|
+
} else if (arg.startsWith("--theme=")) {
|
|
1246
|
+
const value = arg.slice("--theme=".length);
|
|
1247
|
+
if (!isThemeMode(value)) return { ...result, error: `unknown theme \u201C${value}\u201D \u2014 use auto, light, or dark` };
|
|
1248
|
+
result.themeMode = value;
|
|
1249
|
+
} else if (arg.startsWith("-")) {
|
|
1250
|
+
return { ...result, error: `unknown option \u201C${arg}\u201D` };
|
|
1251
|
+
} else {
|
|
1252
|
+
positional.push(arg);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
if (positional.length > 1) return { ...result, error: "expected a single url" };
|
|
1256
|
+
result.initialUrl = positional[0];
|
|
1257
|
+
return result;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// src/lib/clipboard.ts
|
|
1261
|
+
import { execFileSync } from "child_process";
|
|
1262
|
+
var COMMANDS = process.platform === "darwin" ? [["pbpaste", []]] : process.platform === "win32" ? [["powershell", ["-NoProfile", "-Command", "Get-Clipboard"]]] : [
|
|
1263
|
+
["wl-paste", ["--no-newline"]],
|
|
1264
|
+
["xclip", ["-selection", "clipboard", "-o"]],
|
|
1265
|
+
["xsel", ["--clipboard", "--output"]]
|
|
1266
|
+
];
|
|
1267
|
+
function readClipboard() {
|
|
1268
|
+
for (const [command, args2] of COMMANDS) {
|
|
1269
|
+
try {
|
|
1270
|
+
return execFileSync(command, args2, { encoding: "utf8", timeout: 500, stdio: ["ignore", "pipe", "ignore"] });
|
|
1271
|
+
} catch {
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
return "";
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// src/cli.tsx
|
|
1278
|
+
import { jsx as jsx9 } from "react/jsx-runtime";
|
|
1279
|
+
var VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
1280
|
+
var HELP = `
|
|
1281
|
+
yeet \u2014 yeet any video. paste. yeet. done.
|
|
1282
|
+
|
|
1283
|
+
Usage
|
|
1284
|
+
$ yeet [url]
|
|
1285
|
+
|
|
1286
|
+
Examples
|
|
1287
|
+
$ yeet https://youtu.be/dQw4w9WgXcQ
|
|
1288
|
+
$ yeet https://x.com/user/status/123456
|
|
1289
|
+
$ yeet (prompts for a url)
|
|
1290
|
+
|
|
1291
|
+
Options
|
|
1292
|
+
--theme <mode> use auto, light, or dark for this run
|
|
1293
|
+
-h, --help show this help
|
|
1294
|
+
-v, --version show version
|
|
1295
|
+
|
|
1296
|
+
Downloads are saved to ~/Downloads.
|
|
1297
|
+
Powered by yt-dlp \u2014 YouTube, X, Instagram, Threads, TikTok & 1800+ sites.
|
|
1298
|
+
`;
|
|
1299
|
+
var args = parseArgs(process.argv.slice(2));
|
|
1300
|
+
if (args.error) {
|
|
1301
|
+
console.error(`yeet: ${args.error}
|
|
1302
|
+
Try \u201Cyeet --help\u201D for usage.`);
|
|
1303
|
+
process.exit(1);
|
|
1304
|
+
}
|
|
1305
|
+
if (args.help) {
|
|
1306
|
+
console.log(HELP);
|
|
1307
|
+
process.exit(0);
|
|
1308
|
+
}
|
|
1309
|
+
if (args.version) {
|
|
1310
|
+
console.log(VERSION);
|
|
1311
|
+
process.exit(0);
|
|
1312
|
+
}
|
|
1313
|
+
var initialUrl = args.initialUrl;
|
|
1314
|
+
var initialThemeMode = args.themeMode ?? "auto";
|
|
1315
|
+
var isTTY = Boolean(process.stdout.isTTY);
|
|
1316
|
+
var clipboardUrl;
|
|
1317
|
+
if (!initialUrl && isTTY) {
|
|
1318
|
+
const clipped = readClipboard().trim();
|
|
1319
|
+
if (clipped && !/\s/.test(clipped) && isProbablyUrl(clipped)) clipboardUrl = clipped;
|
|
1320
|
+
}
|
|
1321
|
+
var enterAltScreen = () => process.stdout.write("\x1B[?1049h\x1B[H");
|
|
1322
|
+
var leaveAltScreen = () => process.stdout.write("\x1B[?1006l\x1B[?1000l\x1B[?1049l");
|
|
1323
|
+
if (isTTY) {
|
|
1324
|
+
enterAltScreen();
|
|
1325
|
+
process.on("exit", leaveAltScreen);
|
|
1326
|
+
for (const event of ["uncaughtException", "unhandledRejection"]) {
|
|
1327
|
+
process.on(event, (error) => {
|
|
1328
|
+
leaveAltScreen();
|
|
1329
|
+
console.error(error);
|
|
1330
|
+
process.exit(1);
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
var outcome = {};
|
|
1335
|
+
var { waitUntilExit } = render(
|
|
1336
|
+
/* @__PURE__ */ jsx9(
|
|
1337
|
+
App,
|
|
1338
|
+
{
|
|
1339
|
+
initialUrl,
|
|
1340
|
+
clipboardUrl,
|
|
1341
|
+
initialThemeMode,
|
|
1342
|
+
onOutcome: (result) => outcome = result
|
|
1343
|
+
}
|
|
1344
|
+
),
|
|
1345
|
+
// keep a copy of every frame so clicks can be hit-tested against it
|
|
1346
|
+
{ stdout: captureFrames(process.stdout) }
|
|
1347
|
+
);
|
|
1348
|
+
await waitUntilExit();
|
|
1349
|
+
if (isTTY) leaveAltScreen();
|
|
1350
|
+
if (outcome.filepath) {
|
|
1351
|
+
console.log(`\u2713 yeeted \u2192 ${outcome.filepath}`);
|
|
1352
|
+
}
|