@frixaco/anitrack 0.0.7 → 0.0.9
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/index.ts +358 -108
- package/package.json +11 -7
- package/progress-bar.ts +100 -0
package/index.ts
CHANGED
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
// TORRENT SEARCH APP
|
|
3
3
|
// - Search input with loading bar
|
|
4
4
|
// - Virtual windowing results list
|
|
5
|
-
// - Keyboard navigation (j/k/h/l)
|
|
5
|
+
// - Keyboard navigation (j/k/h/l/tab)
|
|
6
6
|
|
|
7
7
|
import { existsSync } from "fs";
|
|
8
8
|
import { COLORS } from "./colors";
|
|
9
|
-
import { Button, Column, Input, Row, run, onKey } from "@frixaco/letui";
|
|
10
|
-
import { $, ff
|
|
9
|
+
import { Button, Column, Input, Row, Text, run, onKey } from "@frixaco/letui";
|
|
10
|
+
import { $, ff } from "@frixaco/letui";
|
|
11
11
|
import { LoadingBar } from "./progress-bar";
|
|
12
12
|
|
|
13
|
-
// --- Types ---
|
|
14
13
|
type ScrapeResultItem = {
|
|
15
14
|
title: string;
|
|
16
15
|
size: string;
|
|
@@ -18,173 +17,390 @@ type ScrapeResultItem = {
|
|
|
18
17
|
magnet: string;
|
|
19
18
|
};
|
|
20
19
|
|
|
21
|
-
type ScrapeResult = {
|
|
22
|
-
results: ScrapeResultItem[];
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
type TorrentDetails = {
|
|
26
|
-
id: number;
|
|
27
|
-
info_hash: string;
|
|
28
|
-
name: string;
|
|
29
|
-
files: unknown[];
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
type TorrentResponse = {
|
|
33
|
-
id: number;
|
|
34
|
-
details: TorrentDetails;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
// --- State ---
|
|
38
20
|
const results = $<ScrapeResultItem[]>([]);
|
|
39
21
|
const loading = $(false);
|
|
40
22
|
const selectedIndex = $(0);
|
|
23
|
+
const focusTarget = $<"input" | "results">("input");
|
|
24
|
+
const MPV_SOCKET_WAIT_MS = 5000;
|
|
41
25
|
|
|
42
|
-
// --- Loading Bars ---
|
|
43
26
|
const loadingBar = LoadingBar({
|
|
44
|
-
dotColor: COLORS.default.
|
|
45
|
-
trackColor: COLORS.default.
|
|
27
|
+
dotColor: COLORS.default.cyan,
|
|
28
|
+
trackColor: COLORS.default.bg_highlight,
|
|
46
29
|
});
|
|
47
30
|
|
|
48
|
-
|
|
31
|
+
function toScrapeResults(payload: unknown): ScrapeResultItem[] {
|
|
32
|
+
const rawResults =
|
|
33
|
+
payload && typeof payload === "object" ? (payload as any).results : undefined;
|
|
34
|
+
if (!Array.isArray(rawResults)) return [];
|
|
35
|
+
|
|
36
|
+
const normalized: ScrapeResultItem[] = [];
|
|
37
|
+
for (const item of rawResults) {
|
|
38
|
+
if (!item || typeof item !== "object") continue;
|
|
39
|
+
|
|
40
|
+
normalized.push({
|
|
41
|
+
title: String((item as any).title ?? ""),
|
|
42
|
+
size: String((item as any).size ?? ""),
|
|
43
|
+
date: String((item as any).date ?? ""),
|
|
44
|
+
magnet: String((item as any).magnet ?? ""),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return normalized;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toStreamTarget(payload: unknown): { infoHash: string; fileIndex: number } | null {
|
|
51
|
+
if (!payload || typeof payload !== "object") return null;
|
|
52
|
+
const details = (payload as any).details;
|
|
53
|
+
if (!details || typeof details !== "object") return null;
|
|
54
|
+
|
|
55
|
+
const infoHash = details.info_hash;
|
|
56
|
+
const files = details.files;
|
|
57
|
+
|
|
58
|
+
if (typeof infoHash !== "string" || !Array.isArray(files) || files.length === 0) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
infoHash,
|
|
64
|
+
fileIndex: files.length - 1,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resetResultsToInput(): void {
|
|
69
|
+
results([]);
|
|
70
|
+
selectedIndex(0);
|
|
71
|
+
focusTarget("input");
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
async function fetchResults(query: string) {
|
|
50
75
|
loading(true);
|
|
51
76
|
loadingBar.start();
|
|
52
77
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch(
|
|
80
|
+
`https://scrape.anitrack.frixaco.com/scrape?q=${encodeURIComponent(query)}`,
|
|
81
|
+
);
|
|
82
|
+
if (!response.ok) {
|
|
83
|
+
throw new Error(`Search failed with status ${response.status}`);
|
|
84
|
+
}
|
|
85
|
+
const payload = await response.json();
|
|
86
|
+
const parsedResults = toScrapeResults(payload);
|
|
87
|
+
results(parsedResults);
|
|
88
|
+
selectedIndex(0);
|
|
89
|
+
focusTarget(parsedResults.length > 0 ? "results" : "input");
|
|
90
|
+
} catch {
|
|
91
|
+
resetResultsToInput();
|
|
92
|
+
} finally {
|
|
93
|
+
loading(false);
|
|
94
|
+
loadingBar.stop();
|
|
95
|
+
}
|
|
62
96
|
}
|
|
63
97
|
|
|
64
98
|
async function streamResult(magnet: string) {
|
|
65
99
|
loadingBar.start();
|
|
66
100
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const ipcPath = `/tmp/mpv-socket-${Date.now()}`;
|
|
77
|
-
Bun.spawn({
|
|
78
|
-
cmd: ["mpv", `--input-ipc-server=${ipcPath}`, streamUrl],
|
|
79
|
-
stdout: "ignore",
|
|
80
|
-
stderr: "ignore",
|
|
81
|
-
});
|
|
101
|
+
try {
|
|
102
|
+
const response = await fetch("https://rqbit.anitrack.frixaco.com/torrents", {
|
|
103
|
+
method: "post",
|
|
104
|
+
body: magnet,
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new Error(`Stream failed with status ${response.status}`);
|
|
108
|
+
}
|
|
82
109
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
110
|
+
const payload = await response.json();
|
|
111
|
+
const target = toStreamTarget(payload);
|
|
112
|
+
if (!target) return;
|
|
87
113
|
|
|
88
|
-
|
|
89
|
-
}
|
|
114
|
+
const streamUrl = `https://rqbit.anitrack.frixaco.com/torrents/${target.infoHash}/stream/${target.fileIndex}`;
|
|
90
115
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
116
|
+
const ipcPath = `/tmp/mpv-socket-${Date.now()}`;
|
|
117
|
+
Bun.spawn({
|
|
118
|
+
cmd: ["mpv", `--input-ipc-server=${ipcPath}`, streamUrl],
|
|
119
|
+
stdout: "ignore",
|
|
120
|
+
stderr: "ignore",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const deadline = Date.now() + MPV_SOCKET_WAIT_MS;
|
|
124
|
+
while (!existsSync(ipcPath) && Date.now() < deadline) {
|
|
125
|
+
await Bun.sleep(50);
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// Keep UI alive even when backend endpoints are unavailable.
|
|
129
|
+
} finally {
|
|
130
|
+
loadingBar.stop();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
96
133
|
|
|
97
134
|
const focusedBorderStyle = {
|
|
98
135
|
color: COLORS.default.green,
|
|
99
|
-
style: "
|
|
136
|
+
style: "rounded" as const,
|
|
100
137
|
};
|
|
101
138
|
|
|
102
|
-
|
|
139
|
+
const titleLine = Text({
|
|
140
|
+
text: "ANITRACK // TORRENT SEARCH + STREAM STAGING",
|
|
141
|
+
foreground: COLORS.default.cyan,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const subtitleLine = Text({
|
|
145
|
+
text: "keyboard-first flow with wrapped panels and live result status",
|
|
146
|
+
foreground: COLORS.default.grey,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const searchStatusLine = Text({
|
|
150
|
+
text: "",
|
|
151
|
+
foreground: COLORS.default.fg,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const resultsSummaryLine = Text({
|
|
155
|
+
text: "",
|
|
156
|
+
foreground: COLORS.default.fg,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const helpLine = Text({
|
|
160
|
+
text: "/ focus input Tab switch pane j/k navigate Enter stream q quit",
|
|
161
|
+
foreground: COLORS.default.grey,
|
|
162
|
+
});
|
|
163
|
+
|
|
103
164
|
const searchInput = Input({
|
|
104
165
|
placeholder: "Search torrents...",
|
|
105
|
-
border:
|
|
166
|
+
border: undefined,
|
|
106
167
|
padding: "1 0",
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
168
|
+
foreground: COLORS.default.fg,
|
|
169
|
+
height: 3,
|
|
170
|
+
minWidth: 28,
|
|
171
|
+
onSubmit: (val) => {
|
|
172
|
+
const query = val.trim();
|
|
173
|
+
if (query.length === 0) {
|
|
174
|
+
resetResultsToInput();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
fetchResults(query);
|
|
178
|
+
},
|
|
179
|
+
onFocus: (self) => {
|
|
180
|
+
focusTarget("input");
|
|
181
|
+
self.setStyle({ border: focusedBorderStyle });
|
|
182
|
+
},
|
|
183
|
+
onBlur: (self) => self.setStyle({ border: undefined }),
|
|
110
184
|
});
|
|
111
|
-
whenSettled(() => searchInput.focus());
|
|
112
185
|
|
|
113
|
-
const loadingBars = Row({
|
|
186
|
+
const loadingBars = Row({}, [loadingBar.node]);
|
|
187
|
+
const searchInputRow = Row({ minHeight: 3, alignItems: "stretch" }, [searchInput]);
|
|
188
|
+
|
|
189
|
+
const resultsList = Column({ padding: "1 0", flexGrow: 1 }, []);
|
|
190
|
+
|
|
191
|
+
const searchPanel = Column(
|
|
192
|
+
{
|
|
193
|
+
gap: 1,
|
|
194
|
+
padding: "0 0",
|
|
195
|
+
flexGrow: 1,
|
|
196
|
+
flexBasis: 32,
|
|
197
|
+
minWidth: 28,
|
|
198
|
+
},
|
|
199
|
+
[titleLine, subtitleLine, searchInputRow, loadingBars, searchStatusLine],
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const resultsPanel = Column(
|
|
203
|
+
{
|
|
204
|
+
gap: 1,
|
|
205
|
+
padding: "0 0",
|
|
206
|
+
flexGrow: 2,
|
|
207
|
+
flexBasis: 50,
|
|
208
|
+
minWidth: 40,
|
|
209
|
+
minHeight: 14,
|
|
210
|
+
},
|
|
211
|
+
[resultsSummaryLine, resultsList, helpLine],
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const root = Column(
|
|
215
|
+
{
|
|
216
|
+
flexGrow: 1,
|
|
217
|
+
gap: 1,
|
|
218
|
+
padding: "1 1",
|
|
219
|
+
},
|
|
220
|
+
[
|
|
221
|
+
Row(
|
|
222
|
+
{
|
|
223
|
+
flexGrow: 1,
|
|
224
|
+
gap: 1,
|
|
225
|
+
flexWrap: "wrap",
|
|
226
|
+
alignItems: "stretch",
|
|
227
|
+
},
|
|
228
|
+
[searchPanel, resultsPanel],
|
|
229
|
+
),
|
|
230
|
+
],
|
|
231
|
+
);
|
|
114
232
|
|
|
115
|
-
const resultsList = Column({ gap: 1, padding: "1 0", flexGrow: 1 }, []);
|
|
116
|
-
|
|
117
|
-
const root = Column({ border: borderStyle, gap: 1, padding: "1 0" }, [
|
|
118
|
-
Column({ padding: "1 0" }, [searchInput, loadingBars]),
|
|
119
|
-
resultsList,
|
|
120
|
-
]);
|
|
121
|
-
|
|
122
|
-
// --- Keep track of result buttons for focus management ---
|
|
123
233
|
let resultButtons: ReturnType<typeof Button>[] = [];
|
|
234
|
+
let visibleStartIndex = 0;
|
|
235
|
+
let lastResultsSnapshot: ScrapeResultItem[] | null = null;
|
|
236
|
+
const resultHeights = new Map<number, number>();
|
|
124
237
|
|
|
125
|
-
|
|
238
|
+
function resultLabel(item: ScrapeResultItem, isActive: boolean): string {
|
|
239
|
+
return `${isActive ? "▶ " : " "}${item.title}`;
|
|
240
|
+
}
|
|
126
241
|
|
|
127
|
-
// Update results list with virtual windowing
|
|
128
242
|
ff(() => {
|
|
129
243
|
const all = results();
|
|
130
244
|
const selected = selectedIndex();
|
|
245
|
+
const isLoading = loading();
|
|
246
|
+
const activePane = focusTarget();
|
|
247
|
+
|
|
248
|
+
titleLine.setStyle({
|
|
249
|
+
foreground:
|
|
250
|
+
activePane === "input" ? COLORS.default.green : COLORS.default.cyan,
|
|
251
|
+
});
|
|
252
|
+
searchStatusLine.setText(
|
|
253
|
+
isLoading
|
|
254
|
+
? "status: searching remote scrape endpoint..."
|
|
255
|
+
: activePane === "input"
|
|
256
|
+
? "status: input armed; submit query to populate results"
|
|
257
|
+
: "status: results active; input still available with /",
|
|
258
|
+
);
|
|
259
|
+
searchStatusLine.setStyle({
|
|
260
|
+
foreground:
|
|
261
|
+
isLoading
|
|
262
|
+
? COLORS.default.yellow
|
|
263
|
+
: activePane === "input"
|
|
264
|
+
? COLORS.default.green
|
|
265
|
+
: COLORS.default.grey,
|
|
266
|
+
});
|
|
267
|
+
resultsSummaryLine.setText(
|
|
268
|
+
all.length === 0
|
|
269
|
+
? "results: empty"
|
|
270
|
+
: `results: ${all.length} items selected: ${Math.min(all.length, selected + 1)}/${all.length}`,
|
|
271
|
+
);
|
|
272
|
+
resultsSummaryLine.setStyle({
|
|
273
|
+
foreground:
|
|
274
|
+
all.length === 0
|
|
275
|
+
? COLORS.default.grey
|
|
276
|
+
: activePane === "results"
|
|
277
|
+
? COLORS.default.green
|
|
278
|
+
: COLORS.default.cyan,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
if (all !== lastResultsSnapshot) {
|
|
282
|
+
resultHeights.clear();
|
|
283
|
+
lastResultsSnapshot = all;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (let i = 0; i < resultButtons.length; i++) {
|
|
287
|
+
const measuredHeight = Math.floor(resultButtons[i]?.frameHeight() ?? 0);
|
|
288
|
+
if (measuredHeight > 0) {
|
|
289
|
+
resultHeights.set(visibleStartIndex + i, measuredHeight);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
131
292
|
|
|
132
293
|
if (all.length === 0) {
|
|
294
|
+
visibleStartIndex = 0;
|
|
295
|
+
resultButtons = [];
|
|
133
296
|
resultsList.setChildren?.([]);
|
|
297
|
+
if (focusTarget() === "results") {
|
|
298
|
+
focusInput();
|
|
299
|
+
}
|
|
134
300
|
return;
|
|
135
301
|
}
|
|
136
302
|
|
|
137
|
-
|
|
138
|
-
|
|
303
|
+
const clampedSelected = Math.max(0, Math.min(selected, all.length - 1));
|
|
304
|
+
if (clampedSelected !== selected) {
|
|
305
|
+
selectedIndex(clampedSelected);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
139
308
|
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
309
|
+
const availableHeight = Math.max(1, Math.floor(resultsList.frameHeight()));
|
|
310
|
+
const fallbackHeight = Math.max(
|
|
311
|
+
1,
|
|
312
|
+
Math.floor(
|
|
313
|
+
resultHeights.get(selected) ??
|
|
314
|
+
resultButtons[0]?.frameHeight() ??
|
|
315
|
+
searchInput.frameHeight(),
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
const itemHeightAt = (index: number) =>
|
|
319
|
+
Math.max(1, Math.floor(resultHeights.get(index) ?? fallbackHeight));
|
|
320
|
+
|
|
321
|
+
let start = selected;
|
|
322
|
+
let usedHeight = 0;
|
|
323
|
+
while (start >= 0) {
|
|
324
|
+
const height = itemHeightAt(start);
|
|
325
|
+
if (usedHeight + height > availableHeight) {
|
|
326
|
+
if (usedHeight > 0) {
|
|
327
|
+
start += 1;
|
|
328
|
+
}
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
usedHeight += height;
|
|
332
|
+
if (start === 0) break;
|
|
333
|
+
start -= 1;
|
|
334
|
+
}
|
|
335
|
+
start = Math.max(0, start);
|
|
336
|
+
|
|
337
|
+
let end = start;
|
|
338
|
+
let windowHeight = 0;
|
|
339
|
+
while (end < all.length) {
|
|
340
|
+
const height = itemHeightAt(end);
|
|
341
|
+
if (windowHeight + height > availableHeight) {
|
|
342
|
+
if (end === start) {
|
|
343
|
+
end += 1;
|
|
344
|
+
}
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
windowHeight += height;
|
|
348
|
+
end += 1;
|
|
349
|
+
}
|
|
143
350
|
|
|
144
|
-
// Calculate window with selection at bottom (scroll only when needed)
|
|
145
|
-
let start = selected - visibleCount + 1;
|
|
146
|
-
start = Math.max(0, Math.min(start, all.length - visibleCount));
|
|
147
|
-
const end = Math.min(start + visibleCount, all.length);
|
|
148
351
|
const visible = all.slice(start, end);
|
|
352
|
+
visibleStartIndex = start;
|
|
149
353
|
|
|
150
354
|
resultButtons = visible.map((item, i) => {
|
|
151
355
|
const globalIdx = start + i;
|
|
152
356
|
const isActive = globalIdx === selected;
|
|
153
357
|
return Button({
|
|
154
|
-
text:
|
|
155
|
-
border:
|
|
156
|
-
|
|
157
|
-
|
|
358
|
+
text: resultLabel(item, isActive),
|
|
359
|
+
border: undefined,
|
|
360
|
+
foreground: isActive ? COLORS.default.green : COLORS.default.fg,
|
|
361
|
+
onFocus: () => {
|
|
362
|
+
focusTarget("results");
|
|
363
|
+
if (selectedIndex() !== globalIdx) {
|
|
364
|
+
selectedIndex(globalIdx);
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
onClick: () => {
|
|
368
|
+
focusTarget("results");
|
|
369
|
+
selectedIndex(globalIdx);
|
|
370
|
+
streamResult(item.magnet);
|
|
371
|
+
},
|
|
158
372
|
});
|
|
159
373
|
});
|
|
160
374
|
|
|
161
375
|
resultsList.setChildren?.(resultButtons);
|
|
162
376
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (resultButtons[selectedVisibleIndex]) {
|
|
166
|
-
resultButtons[selectedVisibleIndex].focus();
|
|
377
|
+
if (focusTarget() === "results") {
|
|
378
|
+
focusSelectedResult();
|
|
167
379
|
}
|
|
168
380
|
});
|
|
169
381
|
|
|
170
|
-
|
|
171
|
-
onKey("
|
|
172
|
-
|
|
382
|
+
onKey("/", () => focusInput());
|
|
383
|
+
onKey("\t", () => toggleFocusTarget());
|
|
384
|
+
onKey("\x1b[Z", () => toggleFocusTarget());
|
|
173
385
|
onKey("j", () => selectNext());
|
|
174
|
-
onKey("\x1b[B", () => selectNext());
|
|
175
|
-
|
|
386
|
+
onKey("\x1b[B", () => selectNext());
|
|
176
387
|
onKey("k", () => selectPrev());
|
|
177
|
-
onKey("\x1b[A", () => selectPrev());
|
|
178
|
-
|
|
388
|
+
onKey("\x1b[A", () => selectPrev());
|
|
179
389
|
onKey("l", () => selectLast());
|
|
180
|
-
onKey("\x1b[C", () => selectLast());
|
|
181
|
-
|
|
390
|
+
onKey("\x1b[C", () => selectLast());
|
|
182
391
|
onKey("h", () => selectFirst());
|
|
183
|
-
onKey("\x1b[D", () => selectFirst());
|
|
392
|
+
onKey("\x1b[D", () => selectFirst());
|
|
393
|
+
|
|
394
|
+
const app = run(root, {
|
|
395
|
+
debug: true,
|
|
396
|
+
});
|
|
184
397
|
|
|
185
|
-
onKey("q", () =>
|
|
398
|
+
onKey("q", () => {
|
|
399
|
+
app.quit();
|
|
400
|
+
});
|
|
186
401
|
|
|
187
402
|
function selectNext() {
|
|
403
|
+
if (focusTarget() !== "results") return;
|
|
188
404
|
const max = results().length - 1;
|
|
189
405
|
if (selectedIndex() < max) {
|
|
190
406
|
selectedIndex(selectedIndex() + 1);
|
|
@@ -192,21 +408,55 @@ function selectNext() {
|
|
|
192
408
|
}
|
|
193
409
|
|
|
194
410
|
function selectPrev() {
|
|
411
|
+
if (focusTarget() !== "results") return;
|
|
195
412
|
if (selectedIndex() > 0) {
|
|
196
413
|
selectedIndex(selectedIndex() - 1);
|
|
197
414
|
}
|
|
198
415
|
}
|
|
199
416
|
|
|
200
417
|
function selectFirst() {
|
|
418
|
+
if (focusTarget() !== "results") return;
|
|
201
419
|
selectedIndex(0);
|
|
202
420
|
}
|
|
203
421
|
|
|
204
422
|
function selectLast() {
|
|
423
|
+
if (focusTarget() !== "results") return;
|
|
205
424
|
const max = results().length - 1;
|
|
206
425
|
if (max >= 0) {
|
|
207
426
|
selectedIndex(max);
|
|
208
427
|
}
|
|
209
428
|
}
|
|
210
429
|
|
|
211
|
-
|
|
212
|
-
|
|
430
|
+
function focusInput() {
|
|
431
|
+
focusTarget("input");
|
|
432
|
+
searchInput.focus();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function focusSelectedResult() {
|
|
436
|
+
if (results().length === 0) {
|
|
437
|
+
focusInput();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
focusTarget("results");
|
|
441
|
+
const selectedVisibleIndex = selectedIndex() - visibleStartIndex;
|
|
442
|
+
const selectedButton = resultButtons[selectedVisibleIndex];
|
|
443
|
+
if (selectedButton) {
|
|
444
|
+
selectedButton.focus();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
resultButtons[0]?.focus();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function toggleFocusTarget() {
|
|
451
|
+
if (results().length === 0) {
|
|
452
|
+
focusInput();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (focusTarget() === "input") {
|
|
456
|
+
focusSelectedResult();
|
|
457
|
+
} else {
|
|
458
|
+
focusInput();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
focusInput();
|
package/package.json
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frixaco/anitrack",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"module": "index.ts",
|
|
3
|
+
"version": "0.0.9",
|
|
5
4
|
"type": "module",
|
|
6
5
|
"bin": {
|
|
7
6
|
"anitrack": "./index.ts"
|
|
8
7
|
},
|
|
9
8
|
"files": [
|
|
10
9
|
"index.ts",
|
|
11
|
-
"colors.ts"
|
|
10
|
+
"colors.ts",
|
|
11
|
+
"progress-bar.ts",
|
|
12
|
+
"README.md"
|
|
12
13
|
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"bun": ">=1.0.0"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
13
20
|
"scripts": {
|
|
14
21
|
"dev": "bun run index.ts"
|
|
15
22
|
},
|
|
16
23
|
"dependencies": {
|
|
17
|
-
"@frixaco/letui": "^0.0.
|
|
24
|
+
"@frixaco/letui": "^0.0.12"
|
|
18
25
|
},
|
|
19
26
|
"devDependencies": {
|
|
20
27
|
"@types/bun": "1.3.9"
|
|
21
|
-
},
|
|
22
|
-
"peerDependencies": {
|
|
23
|
-
"typescript": "^5"
|
|
24
28
|
}
|
|
25
29
|
}
|
package/progress-bar.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Node } from "@frixaco/letui";
|
|
2
|
+
import { $, ff, Row, Text } from "@frixaco/letui";
|
|
3
|
+
|
|
4
|
+
export type LoadingBarProps = {
|
|
5
|
+
dotColor: number;
|
|
6
|
+
trackColor: number;
|
|
7
|
+
flexGrow?: number;
|
|
8
|
+
interval?: number; // ms between frames
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type LoadingBarController = {
|
|
12
|
+
node: Node;
|
|
13
|
+
start: () => void;
|
|
14
|
+
stop: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function LoadingBar(props: LoadingBarProps): LoadingBarController {
|
|
18
|
+
const { dotColor, trackColor, flexGrow = 1, interval = 80 } = props;
|
|
19
|
+
|
|
20
|
+
const position = $(0);
|
|
21
|
+
const direction = $(1); // 1 = right, -1 = left
|
|
22
|
+
const active = $(false);
|
|
23
|
+
let timer: Timer | null = null;
|
|
24
|
+
|
|
25
|
+
const leftTrack = Text({
|
|
26
|
+
text: "",
|
|
27
|
+
background: trackColor,
|
|
28
|
+
foreground: trackColor,
|
|
29
|
+
});
|
|
30
|
+
const dot = Text({ text: "", background: dotColor, foreground: dotColor });
|
|
31
|
+
const rightTrack = Text({
|
|
32
|
+
text: "",
|
|
33
|
+
background: trackColor,
|
|
34
|
+
foreground: trackColor,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const node = Row({ flexGrow }, [leftTrack, dot, rightTrack]);
|
|
38
|
+
|
|
39
|
+
// React to position changes
|
|
40
|
+
ff(() => {
|
|
41
|
+
const isActive = active();
|
|
42
|
+
const pos = position();
|
|
43
|
+
const width = node.frameWidth();
|
|
44
|
+
if (width === 0 || !isActive) return;
|
|
45
|
+
|
|
46
|
+
const maxPos = width - 1;
|
|
47
|
+
const clampedPos = Math.max(0, Math.min(pos, maxPos));
|
|
48
|
+
|
|
49
|
+
leftTrack.setText?.(" ".repeat(clampedPos));
|
|
50
|
+
dot.setText?.(" ");
|
|
51
|
+
rightTrack.setText?.(" ".repeat(maxPos - clampedPos));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function clearTimer() {
|
|
55
|
+
if (timer) {
|
|
56
|
+
clearInterval(timer);
|
|
57
|
+
timer = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function start() {
|
|
62
|
+
if (active()) return;
|
|
63
|
+
active(true);
|
|
64
|
+
position(0);
|
|
65
|
+
direction(1);
|
|
66
|
+
|
|
67
|
+
timer = setInterval(() => {
|
|
68
|
+
const width = node.frameWidth();
|
|
69
|
+
if (width === 0) return;
|
|
70
|
+
|
|
71
|
+
const maxPos = width - 1;
|
|
72
|
+
const pos = position();
|
|
73
|
+
const dir = direction();
|
|
74
|
+
|
|
75
|
+
const step = 12;
|
|
76
|
+
const nextPos = pos + dir * step;
|
|
77
|
+
if (nextPos >= maxPos) {
|
|
78
|
+
direction(-1);
|
|
79
|
+
position(maxPos);
|
|
80
|
+
} else if (nextPos <= 0) {
|
|
81
|
+
direction(1);
|
|
82
|
+
position(0);
|
|
83
|
+
} else {
|
|
84
|
+
position(nextPos);
|
|
85
|
+
}
|
|
86
|
+
}, interval);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stop() {
|
|
90
|
+
clearTimer();
|
|
91
|
+
active(false);
|
|
92
|
+
position(0);
|
|
93
|
+
// Clear all three segments
|
|
94
|
+
leftTrack.setText?.("");
|
|
95
|
+
dot.setText?.("");
|
|
96
|
+
rightTrack.setText?.("");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { node, start, stop };
|
|
100
|
+
}
|