- A library past the list's cap is searched and sorted on the server; the count says "the newest". - Each folder and each search opens on its first page (DataTable pageScope, useCardPager scope). - A file dropped where the page takes none never reaches the browser (which would unload the app). - The viewer's arrows wait while a layer opened from it is on top, and scroll a wide text line; a text shows its first 5000 lines and says where the rest is. - Confirms name the file (no more "your downloads"); the asking menu rows end in "…". - The tray names a running step beside its bar, shows a pause mark for paused work, polls slowly while only paused work waits, and forgets a finished file that was deleted. - An unchanged rename sends nothing; a paste opens the download dialog only with nothing on top; the link box never replaces what was typed. - Honest states: folders that failed to load, a file no longer there, the storage figure; the rail says "nothing downloaded" only when there is nothing. - Focus after deleting the open folder and when the format form opens; the format name's label. - ui/Meter (one danger line for both quota bars) and ui/RenameField in the platform layer. - Copy fixes, stale comments and the speculative hub key; a contract test for the list's parameters; e2e: the Files page and the paste intake.
101 lines
4.1 KiB
TypeScript
101 lines
4.1 KiB
TypeScript
import { api, type DownloadStatus, type QueueJob } from "./api";
|
|
import { createStore } from "./store";
|
|
|
|
// The download queue as this tab has watched it: which jobs it has seen finish. The tray shows them
|
|
// ("Done · Open") until it is closed, and announces each once (the done notice).
|
|
//
|
|
// Watched where the queue is FETCHED, not in a component's effect: every reader of the queue fetches
|
|
// through `fetchQueue`, so a finish is seen once whichever of them polled, and nothing has to diff the
|
|
// previous render's data.
|
|
|
|
/** The statuses the tray lists as under way: waiting, working, or paused by its user. */
|
|
export const IN_FLIGHT: ReadonlySet<DownloadStatus> = new Set(["queued", "running", "paused"]);
|
|
|
|
/** The statuses a job leaves on its own, so the queue is worth asking about often. A paused job is
|
|
* not one: it waits for its user, however long that takes. */
|
|
export const MOVING: ReadonlySet<DownloadStatus> = new Set(["queued", "running"]);
|
|
|
|
/** The steps a running job reports that have a name of their own (`downloads.phase.*`). */
|
|
export const DOWNLOAD_PHASES: readonly string[] = [
|
|
"video",
|
|
"audio",
|
|
"editing",
|
|
"converting",
|
|
"merging",
|
|
"audio_extract",
|
|
"thumbnail",
|
|
"sponsorblock",
|
|
"metadata",
|
|
"processing",
|
|
];
|
|
|
|
let lastSeen: Map<number, string> | null = null;
|
|
const finished = createStore<QueueJob[]>([]);
|
|
const unannounced = createStore<QueueJob[]>([]);
|
|
const revealing = createStore<Reveal | null>(null);
|
|
|
|
/** A job to show in the tray, asked for at `at` (ms). */
|
|
export interface Reveal {
|
|
id: number;
|
|
at: number;
|
|
}
|
|
|
|
/** Note a fresh queue: a job that is done now and was not on the last look has just finished. The
|
|
* first look only learns what is there — a file finished before this tab opened is old news. */
|
|
export function noteQueue(jobs: QueueJob[]): void {
|
|
const before = lastSeen;
|
|
lastSeen = new Map(jobs.map((j) => [j.id, j.status]));
|
|
if (!before) return;
|
|
const done = jobs.filter((j) => j.status === "done" && before.get(j.id) !== "done");
|
|
if (done.length === 0) return;
|
|
const ids = new Set(done.map((j) => j.id));
|
|
finished.set((list) => [...done, ...list.filter((j) => !ids.has(j.id))]);
|
|
unannounced.set((list) => [...list, ...done]);
|
|
}
|
|
|
|
/** The queue, noted (see above). Every reader of `qk.downloads()` fetches with this. */
|
|
export async function fetchQueue(): Promise<QueueJob[]> {
|
|
const jobs = await api.downloads();
|
|
noteQueue(jobs);
|
|
return jobs;
|
|
}
|
|
|
|
/** The jobs this tab saw finish, newest first, until the tray is closed. */
|
|
export const useFinished = (): QueueJob[] => finished.useValue();
|
|
|
|
/** The tray was closed: what finished is no longer shown. */
|
|
export const clearFinished = (): void => finished.set([]);
|
|
|
|
/** A file was deleted: a "Done · Open" row for it would open and link nothing. */
|
|
export const forgetFinished = (id: number): void =>
|
|
finished.set((list) => (list.some((j) => j.id === id) ? list.filter((j) => j.id !== id) : list));
|
|
|
|
/** The finishes not yet announced — the render subscription that wakes the announcer. */
|
|
export const useUnannounced = (): QueueJob[] => unannounced.useValue();
|
|
|
|
/** Take the finishes to announce, clearing them: each is announced once. */
|
|
export function takeUnannounced(): QueueJob[] {
|
|
const list = unannounced.get();
|
|
if (list.length) unannounced.set([]);
|
|
return list;
|
|
}
|
|
|
|
/** "View" on a job just started (a download, a clip, an upload still converting): the tray opens on
|
|
* its row — unfolded, back if it was closed, the keyboard on the row. One the tray no longer lists
|
|
* because it is done already opens as a file. */
|
|
export const showInTray = (id: number): void => revealing.set({ id, at: Date.now() });
|
|
|
|
/** The pending "View", until the tray has answered it. */
|
|
export const useRevealing = (): Reveal | null => revealing.useValue();
|
|
|
|
/** The tray answered the "View" (or the job is not in the queue at all). */
|
|
export const doneRevealing = (): void => revealing.set(null);
|
|
|
|
/** Forget what was watched (a test starts from a tab that has looked at nothing). */
|
|
export function resetQueueWatch(): void {
|
|
lastSeen = null;
|
|
finished.set([]);
|
|
unannounced.set([]);
|
|
revealing.set(null);
|
|
}
|